Sfoglia il codice sorgente

feat(free-core): free canvas supports touch operation (#367)

* feat(core): playground drag support drag event

* feat(core): touch move the canvas position

* feat(core): touch move the node position

* feat(core): touch move create or reset line

* test(core): global add TouchList mock

* fix(core): touch drag event disable passive

* fix(core): touch event delay

* feat(port): port click support touch operation

* fix(demo): loop output port line add btn set incorrect container

* test(core): global add TouchList mock

* refactor(core): clean & format codes

* feat(core): getPosFromMouseEvent built-in getEventCoord
Louis Young 7 mesi fa
parent
commit
39dbde28ae
20 ha cambiato i file con 418 aggiunte e 85 eliminazioni
  1. 9 2
      apps/demo-free-layout/src/components/base-node/node-wrapper.tsx
  2. 14 11
      apps/demo-free-layout/src/components/comment/components/drag-area.tsx
  3. 5 10
      apps/demo-free-layout/src/components/group/components/header.tsx
  4. 3 3
      apps/demo-free-layout/src/components/group/components/node-render.tsx
  5. 1 3
      apps/demo-free-layout/src/components/line-add-button/index.tsx
  6. 1 0
      apps/demo-free-layout/src/hooks/index.ts
  7. 83 0
      apps/demo-free-layout/src/hooks/use-port-click.ts
  8. 1 4
      apps/demo-free-layout/src/utils/on-drag-line-end.ts
  9. 12 5
      packages/canvas-engine/core/src/core/layer/config/playground-config-entity.ts
  10. 30 2
      packages/canvas-engine/core/src/core/layer/playground-layer.ts
  11. 1 0
      packages/canvas-engine/core/src/core/utils/index.ts
  12. 117 0
      packages/canvas-engine/core/src/core/utils/mouse-touch-event.ts
  13. 29 16
      packages/canvas-engine/core/src/core/utils/playground-drag.ts
  14. 13 0
      packages/canvas-engine/core/vitest.setup.ts
  15. 12 5
      packages/canvas-engine/free-layout-core/src/hooks/use-node-render.tsx
  16. 5 4
      packages/canvas-engine/free-layout-core/src/service/workflow-drag-service.ts
  17. 19 2
      packages/canvas-engine/free-layout-core/src/service/workflow-hover-service.ts
  18. 13 0
      packages/canvas-engine/free-layout-core/vitest.setup.ts
  19. 39 15
      packages/plugins/free-hover-plugin/src/hover-layer.tsx
  20. 11 3
      packages/plugins/free-lines-plugin/src/components/workflow-port-render/index.tsx

+ 9 - 2
apps/demo-free-layout/src/components/base-node/node-wrapper.tsx

@@ -3,7 +3,7 @@ import React, { useState, useContext } from 'react';
 import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
 import { useClientContext } from '@flowgram.ai/free-layout-editor';
 
-import { useNodeRenderContext } from '../../hooks';
+import { useNodeRenderContext, usePortClick } from '../../hooks';
 import { SidebarContext } from '../../context';
 import { scrollToView } from './utils';
 import { NodeWrapperStyle } from './styles';
@@ -25,8 +25,11 @@ export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
   const sidebar = useContext(SidebarContext);
   const form = nodeRender.form;
   const ctx = useClientContext();
+  const onPortClick = usePortClick();
 
-  const portsRender = ports.map((p) => <WorkflowPortRender key={p.id} entity={p} />);
+  const portsRender = ports.map((p) => (
+    <WorkflowPortRender key={p.id} entity={p} onClick={onPortClick} />
+  ));
 
   return (
     <>
@@ -38,6 +41,10 @@ export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
           startDrag(e);
           setIsDragging(true);
         }}
+        onTouchStart={(e) => {
+          startDrag(e as unknown as React.MouseEvent);
+          setIsDragging(true);
+        }}
         onClick={(e) => {
           selectNode(e);
           if (!isDragging) {

+ 14 - 11
apps/demo-free-layout/src/components/comment/components/drag-area.tsx

@@ -1,4 +1,4 @@
-import { CSSProperties, type FC } from 'react';
+import { CSSProperties, MouseEvent, TouchEvent, type FC } from 'react';
 
 import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
 
@@ -17,22 +17,25 @@ export const DragArea: FC<IDragArea> = (props) => {
 
   const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender();
 
+  const handleDrag = (e: MouseEvent | TouchEvent) => {
+    if (stopEvent) {
+      e.preventDefault();
+      e.stopPropagation();
+    }
+    model.setFocus(false);
+    onStartDrag(e as MouseEvent);
+    selectNode(e as MouseEvent);
+    playground.node.focus(); // 防止节点无法被删除
+  };
+
   return (
     <div
       className="workflow-comment-drag-area"
       data-flow-editor-selectable="false"
       draggable={true}
       style={style}
-      onMouseDown={(e) => {
-        if (stopEvent) {
-          e.preventDefault();
-          e.stopPropagation();
-        }
-        model.setFocus(false);
-        onStartDrag(e);
-        selectNode(e);
-        playground.node.focus(); // 防止节点无法被删除
-      }}
+      onMouseDown={handleDrag}
+      onTouchStart={handleDrag}
       onFocus={onFocus}
       onBlur={onBlur}
     />

+ 5 - 10
apps/demo-free-layout/src/components/group/components/header.tsx

@@ -1,4 +1,4 @@
-import type { FC, ReactNode, MouseEvent, CSSProperties } from 'react';
+import type { FC, ReactNode, MouseEvent, CSSProperties, TouchEvent } from 'react';
 
 import { useWatch } from '@flowgram.ai/free-layout-editor';
 
@@ -6,27 +6,22 @@ import { GroupField } from '../constant';
 import { defaultColor, groupColors } from '../color';
 
 interface GroupHeaderProps {
-  onMouseDown: (e: MouseEvent) => void;
+  onDrag: (e: MouseEvent | TouchEvent) => void;
   onFocus: () => void;
   onBlur: () => void;
   children: ReactNode;
   style?: CSSProperties;
 }
 
-export const GroupHeader: FC<GroupHeaderProps> = ({
-  onMouseDown,
-  onFocus,
-  onBlur,
-  children,
-  style,
-}) => {
+export const GroupHeader: FC<GroupHeaderProps> = ({ onDrag, onFocus, onBlur, children, style }) => {
   const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
   const color = groupColors[colorName];
   return (
     <div
       className="workflow-group-header"
       data-flow-editor-selectable="false"
-      onMouseDown={onMouseDown}
+      onMouseDown={onDrag}
+      onTouchStart={onDrag}
       onFocus={onFocus}
       onBlur={onBlur}
       style={{

+ 3 - 3
apps/demo-free-layout/src/components/group/components/node-render.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { MouseEvent, useEffect } from 'react';
 
 import {
   FlowNodeFormData,
@@ -48,8 +48,8 @@ export const GroupNodeRender = () => {
       <Form control={formControl}>
         <>
           <GroupHeader
-            onMouseDown={(e) => {
-              startDrag(e);
+            onDrag={(e) => {
+              startDrag(e as MouseEvent);
             }}
             onFocus={onFocus}
             onBlur={onBlur}

+ 1 - 3
apps/demo-free-layout/src/components/line-add-button/index.tsx

@@ -39,9 +39,7 @@ export const LineAddButton = (props: LineRenderProps) => {
     };
 
     // get container node for the new node - 获取新节点的容器节点
-    const containerNode = WorkflowNodePanelUtils.getContainerNode({
-      fromPort,
-    });
+    const containerNode = fromPort.node.parent;
 
     // show node selection panel - 显示节点选择面板
     const result = await nodePanelService.singleSelectNodePanel({

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

@@ -1,3 +1,4 @@
 export { useEditorProps } from './use-editor-props';
 export { useNodeRenderContext } from './use-node-render-context';
 export { useIsSidebar } from './use-is-sidebar';
+export { usePortClick } from './use-port-click';

+ 83 - 0
apps/demo-free-layout/src/hooks/use-port-click.ts

@@ -0,0 +1,83 @@
+import { useCallback } from 'react';
+
+import {
+  WorkflowNodePanelService,
+  WorkflowNodePanelUtils,
+} from '@flowgram.ai/free-node-panel-plugin';
+import {
+  delay,
+  usePlayground,
+  useService,
+  WorkflowDocument,
+  WorkflowDragService,
+  WorkflowLinesManager,
+  WorkflowNodeEntity,
+  WorkflowNodeJSON,
+  WorkflowPortEntity,
+} from '@flowgram.ai/free-layout-editor';
+
+/**
+ * click port to trigger node select panel
+ * 点击端口后唤起节点选择面板
+ */
+export const usePortClick = () => {
+  const playground = usePlayground();
+  const nodePanelService = useService(WorkflowNodePanelService);
+  const document = useService(WorkflowDocument);
+  const dragService = useService(WorkflowDragService);
+  const linesManager = useService(WorkflowLinesManager);
+
+  const onPortClick = useCallback(async (e: React.MouseEvent, port: WorkflowPortEntity) => {
+    const mousePos = playground.config.getPosFromMouseEvent(e);
+    const containerNode = port.node.parent;
+    // open node selection panel - 打开节点选择面板
+    const result = await nodePanelService.singleSelectNodePanel({
+      position: mousePos,
+      containerNode,
+      panelProps: {
+        enableScrollClose: true,
+      },
+    });
+
+    // return if no node selected - 如果没有选择节点则返回
+    if (!result) {
+      return;
+    }
+
+    // get selected node type and data - 获取选择的节点类型和数据
+    const { nodeType, nodeJSON } = result;
+
+    // calculate position for the new node - 计算新节点的位置
+    const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
+      nodeType,
+      position: {
+        x: mousePos.x + 100,
+        y: mousePos.y,
+      },
+      fromPort: port,
+      containerNode,
+      document,
+      dragService,
+    });
+
+    // create new workflow node - 创建新的工作流节点
+    const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
+      nodeType,
+      nodePosition,
+      nodeJSON ?? ({} as WorkflowNodeJSON),
+      containerNode?.id
+    );
+
+    // wait for node render - 等待节点渲染
+    await delay(20);
+
+    // build connection line - 构建连接线
+    WorkflowNodePanelUtils.buildLine({
+      fromPort: port,
+      node,
+      linesManager,
+    });
+  }, []);
+
+  return onPortClick;
+};

+ 1 - 4
apps/demo-free-layout/src/utils/on-drag-line-end.ts

@@ -37,9 +37,7 @@ export const onDragLineEnd = async (ctx: FreeLayoutPluginContext, params: onDrag
   }
 
   // get container node for the new node - 获取新节点的容器节点
-  const containerNode = WorkflowNodePanelUtils.getContainerNode({
-    fromPort,
-  });
+  const containerNode = fromPort.node.parent;
 
   // open node selection panel - 打开节点选择面板
   const result = await nodePanelService.singleSelectNodePanel({
@@ -85,7 +83,6 @@ export const onDragLineEnd = async (ctx: FreeLayoutPluginContext, params: onDrag
   WorkflowNodePanelUtils.buildLine({
     fromPort,
     node,
-    toPort,
     linesManager,
   });
 };

+ 12 - 5
packages/canvas-engine/core/src/core/layer/config/playground-config-entity.ts

@@ -8,7 +8,7 @@ import {
   SizeSchema,
   TransformData,
 } from '../../../common'
-import { startTween } from '../../utils'
+import { MouseTouchEvent, startTween } from '../../utils'
 // import { Selectable } from '../../able'
 
 export interface PlaygroundConfigEntityData {
@@ -216,14 +216,21 @@ export class PlaygroundConfigEntity extends ConfigEntity<PlaygroundConfigEntityD
    * @param widthScale 是否要计算缩放
    */
   getPosFromMouseEvent(
-    event: { clientX: number; clientY: number },
+    event:
+    | MouseEvent
+    | TouchEvent
+    | {
+        clientX: number;
+        clientY: number;
+      },
     withScale = true
   ): PositionSchema {
     const { config } = this
-    const scale = withScale ? this.finalScale : 1
+    const scale = withScale ? this.zoom : 1
+    const { clientX, clientY } = MouseTouchEvent.getEventCoord(event)
     return {
-      x: (event.clientX + config.scrollX - config.clientX) / scale,
-      y: (event.clientY + config.scrollY - config.clientY) / scale,
+      x: (clientX + config.scrollX - config.clientX) / scale,
+      y: (clientY + config.scrollY - config.clientY) / scale,
     }
   }
 

+ 30 - 2
packages/canvas-engine/core/src/core/layer/playground-layer.ts

@@ -1,9 +1,9 @@
 import { inject, injectable, optional } from 'inversify';
-import { Disposable, domUtils } from '@flowgram.ai/utils';
+import { Disposable, domUtils, PositionSchema } from '@flowgram.ai/utils';
 
 import { Gesture } from '../utils/use-gesture';
 import { PlaygroundGesture } from '../utils/playground-gesture';
-import { PlaygroundDrag } from '../utils';
+import { MouseTouchEvent, PlaygroundDrag } from '../utils';
 import { type PipelineDimension, PipelineLayerPriority } from '../pipeline';
 import { ProtectWheelArea } from '../../common/protect-wheel-area';
 import { observeEntity } from '../../common';
@@ -35,6 +35,8 @@ export interface PlaygroundLayerOptions extends LayerOptions {
   hoverService?: {
     /** 精确判断当前鼠标位置是否有元素存在 */
     isSomeHovered: () => boolean;
+    updateHoverPosition: (position: PositionSchema, target?: HTMLElement) => void;
+    clearHovered: () => void;
   };
 }
 
@@ -117,6 +119,32 @@ export class PlaygroundLayer extends Layer<PlaygroundLayerOptions> {
         PipelineLayerPriority.BASE_LAYER,
         { passive: true }
       ),
+      /**
+       * 监听触控拖动画布操作
+       */
+      this.listenPlaygroundEvent(
+        'touchstart',
+        (e: TouchEvent) => {
+          const { clientX: x, clientY: y } = MouseTouchEvent.getEventCoord(e);
+          if (!this.options?.hoverService) {
+            return;
+          }
+          this.options.hoverService.updateHoverPosition(
+            {
+              x,
+              y,
+            },
+            e.target as HTMLElement
+          );
+          const isSomeHovered = this.options.hoverService?.isSomeHovered();
+          if (isSomeHovered) {
+            return;
+          }
+          this.grabDragger.start(x, y);
+        },
+        // 这里必须监听 NORMAL_LAYER,该图层最先触发
+        PipelineLayerPriority.NORMAL_LAYER
+      ),
       this.listenPlaygroundEvent(
         'mousedown',
         (e: MouseEvent) => {

+ 1 - 0
packages/canvas-engine/core/src/core/utils/index.ts

@@ -4,3 +4,4 @@ export * from './tween';
 export * from './playground-gesture';
 export { injectByProvider } from './inject-provider-decorators';
 export { lazyInject, LazyInjectContext } from './lazy-inject-decorators';
+export { MouseTouchEvent } from './mouse-touch-event';

+ 117 - 0
packages/canvas-engine/core/src/core/utils/mouse-touch-event.ts

@@ -0,0 +1,117 @@
+export namespace MouseTouchEvent {
+  export const isTouchEvent = (event: TouchEvent | React.TouchEvent): event is TouchEvent =>
+    event?.touches instanceof TouchList;
+
+  export const touchToMouseEvent = (event: Event): MouseEvent | Event => {
+    if (!isTouchEvent(event as TouchEvent)) {
+      return event as MouseEvent;
+    }
+    const touchEvent = event as TouchEvent;
+
+    // 默认获取第一个触摸点
+    const touch = touchEvent.touches[0] || touchEvent.changedTouches[0];
+
+    if (touchEvent.type === 'touchmove') {
+      preventDefault(touchEvent);
+    }
+
+    // 确定对应的鼠标事件类型
+    const mouseEventType = {
+      touchstart: 'mousedown',
+      touchmove: 'mousemove',
+      touchend: 'mouseup',
+      touchcancel: 'mouseup',
+    }[touchEvent.type];
+
+    if (!mouseEventType) {
+      throw new Error(`Unknown touch event type: ${touchEvent.type}`);
+    }
+
+    // 创建新的鼠标事件
+    const mouseEvent = new MouseEvent(mouseEventType, {
+      bubbles: touchEvent.bubbles,
+      cancelable: touchEvent.cancelable,
+      view: touchEvent.view,
+      // 复制触摸点的位置信息
+      clientX: touch.clientX,
+      clientY: touch.clientY,
+      screenX: touch.screenX,
+      screenY: touch.screenY,
+      // 复制修饰键状态
+      ctrlKey: touchEvent.ctrlKey,
+      altKey: touchEvent.altKey,
+      shiftKey: touchEvent.shiftKey,
+      metaKey: touchEvent.metaKey,
+    });
+
+    return mouseEvent;
+  };
+  export const getEventCoord = (
+    e:
+      | MouseEvent
+      | TouchEvent
+      | {
+          clientX: number;
+          clientY: number;
+        }
+  ): {
+    clientX: number;
+    clientY: number;
+  } => {
+    if (isTouchEvent(e as TouchEvent)) {
+      const touchEvent = e as TouchEvent;
+      if (touchEvent.touches.length === 0) {
+        return {
+          clientX: 0,
+          clientY: 0,
+        };
+      }
+      return {
+        clientX: touchEvent.touches[0].clientX,
+        clientY: touchEvent.touches[0].clientY,
+      };
+    } else if (e instanceof MouseEvent) {
+      return {
+        clientX: e.clientX,
+        clientY: e.clientY,
+      };
+    }
+    return {
+      clientX: (e as MouseEvent).clientX,
+      clientY: (e as MouseEvent).clientY,
+    };
+  };
+
+  export const preventDefault = (
+    e: Event | MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
+  ) => {
+    if (e.cancelable) {
+      e.preventDefault();
+    }
+  };
+
+  export const onTouched = (
+    touchStartEvent: React.TouchEvent,
+    callback: (e: MouseEvent) => void
+  ) => {
+    const startTouch = touchStartEvent.changedTouches[0];
+
+    const handleTouchEnd = (touchEndEvent: TouchEvent) => {
+      const endTouch = touchEndEvent.changedTouches[0];
+      const deltaX = endTouch.clientX - startTouch.clientX;
+      const deltaY = endTouch.clientY - startTouch.clientY;
+      // 判断是拖拽还是点击
+      const delta = 5;
+      if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) {
+        // 触发回调
+        const mouseEvent = touchToMouseEvent(touchEndEvent) as MouseEvent;
+        callback(mouseEvent);
+      }
+      document.removeEventListener('touchend', handleTouchEnd);
+      document.removeEventListener('touchcancel', handleTouchEnd);
+    };
+
+    document.addEventListener('touchend', handleTouchEnd);
+    document.addEventListener('touchcancel', handleTouchEnd);
+  };
+}

+ 29 - 16
packages/canvas-engine/core/src/core/utils/playground-drag.ts

@@ -7,10 +7,8 @@ import {
 } from '@flowgram.ai/utils';
 
 import type { PlaygroundConfigEntity } from '../layer/config';
-// import { Dragable, DragablePayload } from '../able';
 import type { PositionSchema } from '../../common/schema/position-schema';
-// import { type Entity } from '../../common/entity';
-// import { type Adsorber } from './adsorber';
+import { MouseTouchEvent } from './mouse-touch-event';
 
 /* istanbul ignore next */
 const SCROLL_DELTA = 4;
@@ -38,7 +36,7 @@ function createMouseEvent(type: string, clientX: number, clientY: number): Mouse
     false,
     false,
     0,
-    null,
+    null
   );
   return event;
 }
@@ -87,9 +85,9 @@ export class PlaygroundDrag<T = undefined> implements Disposable {
   readonly onDragEnd = this.onDragEndEmitter.event;
 
   constructor(options: PlaygroundDragOptions<T> = {}) {
-    if (options.onDragStart) this.onDragStart(e => options.onDragStart!(e, this.context));
-    if (options.onDrag) this.onDrag(e => options.onDrag!(e, this.context));
-    if (options.onDragEnd) this.onDragEnd(e => options.onDragEnd!(e, this.context));
+    if (options.onDragStart) this.onDragStart((e) => options.onDragStart!(e, this.context));
+    if (options.onDrag) this.onDrag((e) => options.onDrag!(e, this.context));
+    if (options.onDragEnd) this.onDragEnd((e) => options.onDragEnd!(e, this.context));
     if (options.stopGlobalEventNames) this._stopGlobalEventNames = options.stopGlobalEventNames;
   }
 
@@ -101,7 +99,7 @@ export class PlaygroundDrag<T = undefined> implements Disposable {
     clientX: number,
     clientY: number,
     entity?: PlaygroundConfigEntity,
-    context?: T,
+    context?: T
   ): Promise<void> {
     if (this._disposed) {
       return Promise.resolve();
@@ -112,7 +110,7 @@ export class PlaygroundDrag<T = undefined> implements Disposable {
     this.context = context;
     this.localId = generateLocalId();
     this._addListeners();
-    this._promise = new Promise(resolve => {
+    this._promise = new Promise((resolve) => {
       this._resolve = resolve;
     });
     this._playgroundConfigEntity = entity;
@@ -141,7 +139,8 @@ export class PlaygroundDrag<T = undefined> implements Disposable {
     this._finalize();
   }
 
-  handleEvent(event: Event): void {
+  handleEvent(_event: Event): void {
+    const event = MouseTouchEvent.touchToMouseEvent(_event);
     switch (event.type) {
       case 'mousemove':
         this._evtMouseMove(event as MouseEvent);
@@ -159,13 +158,13 @@ export class PlaygroundDrag<T = undefined> implements Disposable {
         const mouseup = createMouseEvent(
           'mouseup',
           (event as MouseEvent).clientX,
-          (event as MouseEvent).clientY,
+          (event as MouseEvent).clientY
         );
         this._evtMouseUp(mouseup);
         break;
       default:
         // Stop all other events during drag-drop.
-        event.preventDefault();
+        MouseTouchEvent.preventDefault(event);
         event.stopPropagation();
         break;
     }
@@ -297,11 +296,18 @@ export class PlaygroundDrag<T = undefined> implements Disposable {
    * Add the document event listeners for the drag object.
    */
   private _addListeners(): void {
+    // mouse
     document.addEventListener('mousedown', this, true);
     document.addEventListener('mousemove', this, true);
     document.addEventListener('mouseup', this, true);
 
-    this._stopGlobalEventNames.forEach(_event => {
+    // touch
+    document.addEventListener('touchstart', this, true);
+    document.addEventListener('touchmove', this, { passive: false });
+    document.addEventListener('touchend', this, true);
+    document.addEventListener('touchcancel', this, true);
+
+    this._stopGlobalEventNames.forEach((_event) => {
       document.addEventListener(_event, this, true);
     });
   }
@@ -310,11 +316,18 @@ export class PlaygroundDrag<T = undefined> implements Disposable {
    * Remove the document event listeners for the drag object.
    */
   private _removeListeners(): void {
+    // mouse
     document.removeEventListener('mousedown', this, true);
     document.removeEventListener('mousemove', this, true);
     document.removeEventListener('mouseup', this, true);
 
-    this._stopGlobalEventNames.forEach(_event => {
+    // touch
+    document.removeEventListener('touchstart', this, true);
+    document.removeEventListener('touchmove', this);
+    document.removeEventListener('touchend', this, true);
+    document.removeEventListener('touchcancel', this, true);
+
+    this._stopGlobalEventNames.forEach((_event) => {
       document.removeEventListener(_event, this, true);
     });
   }
@@ -408,7 +421,7 @@ export class PlaygroundDrag<T = undefined> implements Disposable {
       const mouseMove = createMouseEvent(
         'mousemove',
         lastMouseMoveEvent.clientX + delta.x,
-        lastMouseMoveEvent.clientY + delta.y,
+        lastMouseMoveEvent.clientY + delta.y
       );
       const dragEvent = this.getDragEvent(mouseMove);
       this.onDragEmitter.fire(dragEvent);
@@ -444,7 +457,7 @@ export namespace PlaygroundDrag {
   export function startDrag<T>(
     clientX: number,
     clientY: number,
-    opts: PlaygroundDragEntitiesOpts<T> = {},
+    opts: PlaygroundDragEntitiesOpts<T> = {}
   ): Disposable {
     if (dragCache) {
       dragCache.stop(NaN, NaN);

+ 13 - 0
packages/canvas-engine/core/vitest.setup.ts

@@ -1 +1,14 @@
 import 'reflect-metadata';
+
+Object.defineProperty(window, 'TouchList', {
+  value: class TouchList {
+    constructor(touches: Touch[] = []) {
+      touches.forEach((touch, index) => {
+        this[index] = touch;
+      });
+      Object.defineProperty(this, 'length', {
+        value: touches.length,
+      });
+    }
+  },
+});

+ 12 - 5
packages/canvas-engine/free-layout-core/src/hooks/use-node-render.tsx

@@ -4,7 +4,12 @@ import { useCallback, useEffect, useRef, useState, useContext, useMemo } from 'r
 import { useObserve } from '@flowgram.ai/reactive';
 import { getNodeForm } from '@flowgram.ai/node';
 import { FlowNodeRenderData } from '@flowgram.ai/document';
-import { PlaygroundEntityContext, useListenEvents, useService } from '@flowgram.ai/core';
+import {
+  MouseTouchEvent,
+  PlaygroundEntityContext,
+  useListenEvents,
+  useService,
+} from '@flowgram.ai/core';
 
 import { WorkflowDragService, WorkflowSelectService } from '../service';
 import { WorkflowNodePortsData } from '../entity-datas';
@@ -47,13 +52,15 @@ export function useNodeRender(nodeFromProps?: WorkflowNodeEntity): NodeRenderRet
 
   const startDrag = useCallback(
     (e: React.MouseEvent) => {
-      e.preventDefault();
+      MouseTouchEvent.preventDefault(e);
       if (!selectionService.isSelected(node.id)) {
         selectNode(e);
       }
-      // 输入框不能拖拽
-      if (!checkTargetDraggable(e.target) || !checkTargetDraggable(document.activeElement)) {
-        return;
+      if (!MouseTouchEvent.isTouchEvent(e as unknown as React.TouchEvent)) {
+        // 输入框不能拖拽
+        if (!checkTargetDraggable(e.target) || !checkTargetDraggable(document.activeElement)) {
+          return;
+        }
       }
       isDragging.current = true;
       // 拖拽选中的节点

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

@@ -22,6 +22,7 @@ import {
 import { FlowNodeBaseType } from '@flowgram.ai/document';
 import {
   CommandService,
+  MouseTouchEvent,
   PlaygroundConfigEntity,
   PlaygroundDrag,
   type PlaygroundDragEvent,
@@ -205,9 +206,8 @@ export class WorkflowDragService {
         });
       },
     });
-    return dragger
-      .start(triggerEvent.clientX, triggerEvent.clientY, this.playgroundConfig)
-      ?.then(() => dragSuccess);
+    const { clientX, clientY } = MouseTouchEvent.getEventCoord(triggerEvent);
+    return dragger.start(clientX, clientY, this.playgroundConfig)?.then(() => dragSuccess);
   }
 
   /**
@@ -730,7 +730,8 @@ export class WorkflowDragService {
         }
       },
     });
-    await dragger.start(event.clientX, event.clientY, config);
+    const { clientX, clientY } = MouseTouchEvent.getEventCoord(event);
+    await dragger.start(clientX, clientY, config);
     return deferred.promise;
   }
 

+ 19 - 2
packages/canvas-engine/free-layout-core/src/service/workflow-hover-service.ts

@@ -1,5 +1,5 @@
 import { inject, injectable } from 'inversify';
-import { Emitter, type IPoint } from '@flowgram.ai/utils';
+import { Emitter, type PositionSchema } from '@flowgram.ai/utils';
 import { EntityManager } from '@flowgram.ai/core';
 
 import {
@@ -13,6 +13,11 @@ import {
  */
 export type WorkflowEntityHoverable = WorkflowNodeEntity | WorkflowLineEntity | WorkflowPortEntity;
 
+export interface HoverPosition {
+  position: PositionSchema;
+  target?: HTMLElement;
+}
+
 /** @deprecated */
 export type WorkfloEntityHoverable = WorkflowEntityHoverable;
 /**
@@ -24,10 +29,14 @@ export class WorkflowHoverService {
 
   protected onHoveredChangeEmitter = new Emitter<string>();
 
+  protected onUpdateHoverPositionEmitter = new Emitter<HoverPosition>();
+
   readonly onHoveredChange = this.onHoveredChangeEmitter.event;
 
+  readonly onUpdateHoverPosition = this.onUpdateHoverPositionEmitter.event;
+
   // 当前鼠标 hover 位置
-  hoveredPos: IPoint = { x: 0, y: 0 };
+  hoveredPos: PositionSchema = { x: 0, y: 0 };
 
   /**
    * 当前 hovered 的 节点或者线条或者点
@@ -47,6 +56,14 @@ export class WorkflowHoverService {
     }
   }
 
+  updateHoverPosition(position: PositionSchema, target?: HTMLElement): void {
+    this.hoveredPos = position;
+    this.onUpdateHoverPositionEmitter.fire({
+      position,
+      target,
+    });
+  }
+
   /**
    * 清空 hover 内容
    */

+ 13 - 0
packages/canvas-engine/free-layout-core/vitest.setup.ts

@@ -1 +1,14 @@
 import 'reflect-metadata';
+
+Object.defineProperty(window, 'TouchList', {
+  value: class TouchList {
+    constructor(touches: Touch[] = []) {
+      touches.forEach((touch, index) => {
+        this[index] = touch;
+      });
+      Object.defineProperty(this, 'length', {
+        value: touches.length,
+      });
+    }
+  },
+});

+ 39 - 15
packages/plugins/free-hover-plugin/src/hover-layer.tsx

@@ -104,6 +104,15 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
       ...this.options,
     };
     this.toDispose.pushAll([
+      // 监听主动触发的 hover 事件
+      this.hoverService.onUpdateHoverPosition((hoverPosition) => {
+        const { position, target } = hoverPosition;
+        const canvasPosition = this.config.getPosFromMouseEvent({
+          clientX: position.x,
+          clientY: position.y,
+        });
+        this.updateHoveredState(canvasPosition, target);
+      }),
       // 监听画布鼠标移动事件
       this.listenPlaygroundEvent('mousemove', (e: MouseEvent) => {
         this.hoverService.hoveredPos = this.config.getPosFromMouseEvent(e);
@@ -118,27 +127,21 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
         this.updateHoveredState(mousePos, e?.target as HTMLElement);
       }),
       this.selectionService.onSelectionChanged(() => this.autorun()),
+      // 控制触控
+      this.listenPlaygroundEvent('touchstart', (e: MouseEvent): boolean | undefined => {
+        if (!this.isEnabled() || this.isDrawing) {
+          return undefined;
+        }
+        return this.handleDragLine(e);
+      }),
       // 控制选中逻辑
       this.listenPlaygroundEvent('mousedown', (e: MouseEvent): boolean | undefined => {
         if (!this.isEnabled() || this.isDrawing) {
           return undefined;
         }
         const { hoveredNode } = this.hoverService;
-        // 重置线条
-        if (hoveredNode && hoveredNode instanceof WorkflowLineEntity) {
-          this.dragService.resetLine(hoveredNode, e);
-          return true;
-        }
-        if (
-          hoveredNode &&
-          hoveredNode instanceof WorkflowPortEntity &&
-          hoveredNode.portType !== 'input' &&
-          !hoveredNode.disabled &&
-          e.button !== 1
-        ) {
-          e.stopPropagation();
-          e.preventDefault();
-          this.dragService.startDrawingLine(hoveredNode, e);
+        const lineDrag = this.handleDragLine(e);
+        if (lineDrag) {
           return true;
         }
         const mousePos = this.config.getPosFromMouseEvent(e);
@@ -293,4 +296,25 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
       !this.dragService.isDragging
     );
   }
+
+  private handleDragLine(e: MouseEvent): boolean | undefined {
+    const { hoveredNode } = this.hoverService;
+    // 重置线条
+    if (hoveredNode && hoveredNode instanceof WorkflowLineEntity) {
+      this.dragService.resetLine(hoveredNode, e);
+      return true;
+    }
+    if (
+      hoveredNode &&
+      hoveredNode instanceof WorkflowPortEntity &&
+      hoveredNode.portType !== 'input' &&
+      !hoveredNode.disabled &&
+      e.button !== 1
+    ) {
+      e.stopPropagation();
+      e.preventDefault();
+      this.dragService.startDrawingLine(hoveredNode, e);
+      return true;
+    }
+  }
 }

+ 11 - 3
packages/plugins/free-lines-plugin/src/components/workflow-port-render/index.tsx

@@ -8,7 +8,7 @@ import {
   usePlaygroundReadonlyState,
   WorkflowLinesManager,
 } from '@flowgram.ai/free-layout-core';
-import { useService } from '@flowgram.ai/core';
+import { MouseTouchEvent, useService } from '@flowgram.ai/core';
 
 import { PORT_BG_CLASS_NAME } from '../../constants/points';
 import { WorkflowPointStyle } from './style';
@@ -18,7 +18,7 @@ export interface WorkflowPortRenderProps {
   entity: WorkflowPortEntity;
   className?: string;
   style?: React.CSSProperties;
-  onClick?: React.MouseEventHandler<HTMLDivElement>;
+  onClick?: (e: React.MouseEvent<HTMLDivElement>, port: WorkflowPortEntity) => void;
   /** 激活状态颜色 (linked/hovered) */
   primaryColor?: string;
   /** 默认状态颜色 */
@@ -111,7 +111,15 @@ export const WorkflowPortRender: React.FC<WorkflowPortRenderProps> =
       <WorkflowPointStyle
         className={className}
         style={combinedStyle}
-        onClick={onClick}
+        onClick={(e) => onClick?.(e, entity)}
+        onTouchStart={(e) => {
+          if (!onClick) {
+            return;
+          }
+          MouseTouchEvent.onTouched(e, (mouseEvent) => {
+            onClick(mouseEvent as unknown as React.MouseEvent<HTMLDivElement>, entity);
+          });
+        }}
         data-port-entity-id={entity.id}
         data-port-entity-type={entity.portType}
         data-testid="sdk.workflow.canvas.node.port"