【图形编辑器架构】:编辑器的 Canvas 分层事件系统

发布日期:2025年10月3日 | 预计阅读时间:25 分钟
最近在重构编辑器 demo 的时候,我重新梳理了事件层 的实现。 在节点层 → 渲染层之后,本篇重点切换到交互事件系统,也就是 Canvas 如何处理复杂交互,如何设计一个类似 Figma 的独立事件架构。


🧑‍💻 写在开头

点赞 + 收藏 = 支持原创 🤣🤣🤣

上一篇文章我们聊了 数据层与渲染层的绑定机制 ,今天继续推进 ------ 把视角放到事件层。 你会看到一个清晰的五层架构设计,从 DOM 原生事件到业务逻辑处理器,再到渲染层通信,完整展示现代 Canvas 应用的事件流转机制。

本篇你将学到:

  • 为什么 React 事件系统不适合高复杂度的 Canvas 应用
  • Figma 式事件系统的五层架构
  • 中间件、处理器、状态机的完整设计模式
  • CanvasPanHandler(画布平移)的完整落地实现
  • 性能优化策略:按需渲染、RAF 批处理、内存管理

🍎 系列背景 & 延续

这个系列文章主要记录了编辑器从 0 到 1 的实现细节:

  • 节点树架构
  • 渲染层对接 Reconciler + Canvas
  • 数据层与渲染层绑定机制
  • 事件系统(本文重点)

之前的文章:

今天我们聊第 4 篇:事件系统设计

实现效果

🎯 一、引言:为什么需要独立的事件系统?

在构建复杂的 Canvas 应用(如 Figma、Sketch 等设计工具)时,传统的 React 事件或者原生的dom事件面临着严峻的挑战。随着应用复杂度的增加,我们需要处理更精细的用户交互、更复杂的状态管理,以及更高效的渲染性能。

比如mousedown事件,可能处理点击创建,铅笔绘制,拖拽事件,画布平移等,每种类型的事件可能会处理各种的业务逻辑,如何分发,如果处理不好,很容易就会混乱

传统方案的局限性

typescript 复制代码
// 传统React事件处理的问题
const Canvas = () => {
  const handleMouseDown = (e: React.MouseEvent) => {
    // ❌ 问题1: 与React渲染周期耦合,性能受限
    // ❌ 问题2: 事件对象被合成,丢失原生特性
    // ❌ 问题3: 复杂交互状态管理困难
    // ❌ 问题4: 缺乏优先级和中间件机制
  };
  
  return <canvas onMouseDown={handleMouseDown} />;
};

核心问题分析

  1. 性能瓶颈:每次事件都要经过 React 的调度机制,增加不必要的开销
  2. 功能受限:合成事件丢失了原生事件的部分能力,如精确的时间戳、原生控制方法等
  3. 扩展性差:难以实现复杂的事件处理逻辑,如优先级、中间件、状态机等
  4. 耦合度高:事件处理与组件生命周期绑定,难以复用和测试

类Figma编辑器的常见方案

采用了完全独立于框架的事件系统,实现了,统一事件对象,方便内部逻辑统一处理:

  • 高性能:直接处理原生 DOM 事件,绕过框架开销
  • 灵活性:支持复杂的事件处理逻辑和自定义业务需求
  • 可扩展:基于插件化架构,支持中间件和处理器扩展
  • 可测试:完全解耦的设计,便于单元测试和集成测试

🏗️ 二、整体实现思路

事件系统的核心目标是将浏览器原生事件解耦、统一并高效分发,让 Canvas 交互逻辑清晰且可扩展。整个实现流程可分为五大层:

css 复制代码
用户交互 → DOM 原生事件 → 事件工厂层 → 核心管理层 → 中间件层 → 处理器层 → 渲染通信 → Canvas 渲染

具体流程:

  1. DOM 事件层

    • 捕获鼠标、触摸、键盘事件
    • 阻止默认浏览器行为(滚动、右键菜单等)
    • 将事件传入事件系统
  2. 事件工厂层

    • 将原生事件转换为统一的应用事件对象(BaseEvent)
    • 增加时间戳、坐标、状态等元信息
    • 保留对原生事件的控制能力(preventDefault、stopPropagation)
  3. 核心管理层(EventSystem)

    • 单例模式管理全局事件
    • 状态机管理交互状态(idle、hover、dragging、panning 等)
    • 按优先级分发事件,支持责任链模式和短路机制
  4. 中间件层

    • 洋葱模型处理事件
    • 可插拔中间件支持日志、权限、缓存、性能监控等
    • 可在前置或后置阶段处理事件
  5. 处理器层(EventHandler)

    • 封装具体业务逻辑(平移、选择、绘制等)
    • 根据当前工具和交互状态决定是否处理事件
    • 返回处理结果:是否 handled、是否请求渲染、交互状态更新
  6. 渲染通信层

    • 事件处理器通过 EventEmitter 发布渲染请求
    • 渲染系统监听并响应,实现按需重绘
    • 坐标系统状态同步,屏幕坐标 ↔ 世界坐标转换

🔄 三、完整事件流程示意

scss 复制代码
用户拖拽鼠标
     ↓
DOM mousedown事件 → EventSystem.handleDOMEvent()
     ↓  
EventFactory.createMouseEvent() → 标准化事件对象
     ↓
EventSystem.processEvent() → 中间件处理
     ↓
CanvasPanHandler.canHandle() → 检查是否可处理
     ↓
CanvasPanHandler.handleMouseDown() → 开始平移状态
     ↓
返回 { handled: true, newState: "panning" }
     ↓
EventSystem 更新交互状态
     ↓
用户移动鼠标...
     ↓
DOM mousemove事件 → CanvasPanHandler.handleMouseMove()
     ↓
计算位移增量 (deltaX, deltaY)
     ↓
coordinateSystemManager.updateViewPosition()
     ↓
viewManager.updateTranslation() → 更新变换矩阵
     ↓
返回 { handled: true, requestRender: true }
     ↓
eventEmitter.emit("render:request")
     ↓
SkiaLikeRenderer.performRender() → Canvas重绘
     ↓
视觉反馈完成 ✨

🏗️ 四、核心架构:五层分离设计

这个事件系统采用了清晰的分层架构,每一层都有明确的职责边界:

复制代码
┌─────────────────┐
│   处理器层      │ ← 具体业务逻辑(CanvasPanHandler, SelectionHandler等)
├─────────────────┤
│   中间件层      │ ← 横切关注点(日志、权限、缓存等)
├─────────────────┤
│  核心管理层     │ ← 事件分发调度(EventSystem)
├─────────────────┤
│  事件工厂层     │ ← 标准化转换(EventFactory)
├─────────────────┤
│   DOM 事件层    │ ← 原生事件监听
└─────────────────┘

1. 事件工厂层 - 标准化转换

定义统一的事件对象

js 复制代码
// 基础事件
export interface BaseEvent {
  type: string;
  timestamp: number;
  preventDefault: () => void;
  stopPropagation: () => void;
  canceled: boolean;
  propagationStopped: boolean;
}

// 鼠标事件
export interface MouseEvent extends BaseEvent {
  type: "mouse.down" | "mouse.move" | "mouse.up" | "mouse.wheel";
  mousePoint: { x: number; y: number };
}

// 键盘事件
export interface KeyboardEvent extends BaseEvent {
  type: "key.down" | "key.up";
  key: string;
  code: string;
}

转换事件对象

设计目标:将原生 DOM 事件转换为应用层统一的事件对象

typescript 复制代码
class EventFactory {
  static createMouseEvent(nativeEvent: MouseEvent): CustomMouseEvent {
    const point = {
      x: nativeEvent.clientX,
      y: nativeEvent.clientY,
    };

    return {
      type: this.getMouseEventType(nativeEvent.type),
      timestamp: Date.now(), // 精确时间戳
      mousePoint: point,
      canceled: false,
      propagationStopped: false,
      // 🎯 保留原生事件的控制能力
      preventDefault: () => nativeEvent.preventDefault(),
      stopPropagation: () => nativeEvent.stopPropagation(),
    };
  }
}

关键特性

  • 统一接口:消除浏览器和事件差异,提供一致的事件对象
  • 增强信息:添加时间戳、坐标等应用层需要的元数据
  • 保留控制:维持对原生事件的控制能力

绑定DOM事件到Canvas

在前面我们讨论了事件系统的统一和指针抽象,但所有的交互最终都来自 浏览器原生事件 。因此,需要一个 DOM → 事件系统的桥梁,将 Canvas 上的鼠标、触摸、键盘事件统一接入我们设计的事件体系。

js 复制代码
/**
 * 绑定DOM事件到Canvas
 */
private bindDOMEvents(canvas: HTMLCanvasElement): void {
  const listeners = new Map<string, EventListener>();

  // 1️⃣ 鼠标事件
  const mouseEvents = ["mousedown", "mousemove", "mouseup", "wheel"];
  mouseEvents.forEach((eventType) => {
    const listener = (e: Event) => this.handleDOMEvent(e as MouseEvent);
    canvas.addEventListener(eventType, listener, { passive: false });
    listeners.set(eventType, listener);
  });

  // 2️⃣ 阻止右键菜单
  const contextMenuListener = (e: Event) => {
    e.preventDefault();
  };
  canvas.addEventListener("contextmenu", contextMenuListener);
  listeners.set("contextmenu", contextMenuListener);

  // 3️⃣ 键盘事件(绑定到 window)
  const keyboardEvents = ["keydown", "keyup"];
  keyboardEvents.forEach((eventType) => {
    const listener = (e: Event) => this.handleDOMEvent(e as KeyboardEvent);
    window.addEventListener(eventType, listener);
    // 使用特殊前缀标记这些是 window 事件
    listeners.set(`window:${eventType}`, listener);
  });

  this.eventListeners.set(canvas, listeners);
}


/**
* 处理DOM事件
*/
private async handleDOMEvent(
    nativeEvent: MouseEvent | KeyboardEvent
): Promise<void> {
    if (!this.context || !this.isActive) return;

    let event: BaseEvent;

    // 转换为标准化事件
    if (nativeEvent instanceof MouseEvent) {
      event = EventFactory.createMouseEvent(nativeEvent);
    } else {
      event = EventFactory.createKeyboardEvent(nativeEvent);
    }

    // 处理事件
    await this.processEvent(event);
}
  1. 统一存储监听器
    使用 Map<string, EventListener> 记录每个 Canvas 的监听器,方便后续解绑或热更新。
  2. 事件统一处理
    所有原生事件都会传给 handleDOMEvent,在这里完成指针抽象状态机分发 ,例如把 mousedown 转成 pointerDown
  3. 防止默认行为
    wheelcontextmenu 等事件进行 preventDefault(),确保画布交互不受浏览器默认操作干扰。
  4. 键盘事件绑定到 window
    键盘事件与画布尺寸无关,需要全局捕获,因此绑定到 window,同时使用前缀标记,便于管理。

这一步实际上是 事件系统落地的关键环节:从浏览器原生事件进入我们统一的事件体系,为 Canvas 的交互(如平移、缩放、拖拽)提供可靠输入源。

2. 核心管理层 - 事件分发调度

设计目标:提供统一的事件管理和分发机制

typescript 复制代码
export class EventSystem {
  private static instance: EventSystem | null = null;
  private handlers: EventHandler[] = [];
  private middlewares: EventMiddleware[] = [];
  private interactionState: InteractionState = "idle";

  // 🔄 责任链模式:按优先级处理事件
  private async processCoreEvent(event: BaseEvent): Promise<EventResult> {
    const availableHandlers = this.handlers
      .filter(handler => handler.canHandle(event, this.interactionState))
      .sort((a, b) => b.priority - a.priority); // 优先级排序
    
    for (const handler of availableHandlers) {
      const result = await handler.handle(event, this.context);
      if (result.handled) return result; // 短路机制
    }
    
    return { handled: false };
  }
}

核心设计模式

  • 单例模式:确保全局事件管理的一致性
  • 责任链模式:支持多处理器按优先级处理,提供短路机制
  • 状态机模式:维护应用交互状态,支持状态感知的事件处理

3. 中间件层 - 可插拔处理

设计目标:提供横切关注点的处理能力

typescript 复制代码
interface EventMiddleware {
  name: string;
  process(
    event: BaseEvent,
    context: EventContext,
    next: () => Promise<EventResult> // 类似Express的next函数
  ): Promise<EventResult>;
}

// 洋葱模型的中间件处理
private async processMiddlewares(event: BaseEvent, index: number): Promise<EventResult> {
  if (index >= this.middlewares.length) {
    return this.processCoreEvent(event); // 执行核心逻辑
  }

  const middleware = this.middlewares[index];
  const next = () => this.processMiddlewares(event, index + 1);
  
  return middleware.process(event, this.context!, next);
}

设计优势

  • 洋葱模型:类似 Koa/Express 的中间件机制,支持前置和后置处理
  • 可插拔:支持日志、权限验证、性能监控等横切关注点
  • 组合能力:多个中间件可以组合使用,实现复杂的处理逻辑

4. 处理器层 - 业务逻辑

设计目标:封装具体的业务处理逻辑

typescript 复制代码
interface EventHandler {
  name: string;           // 处理器标识
  priority: number;       // 处理优先级
  canHandle(event: BaseEvent, state: InteractionState): boolean; // 过滤条件
  handle(event: BaseEvent, context: EventContext): Promise<EventResult>; // 处理逻辑
}

interface EventResult {
  handled: boolean;        // 是否处理成功
  newState?: InteractionState; // 新的交互状态
  requestRender?: boolean; // 是否需要重新渲染
  data?: Record<string, unknown>; // 附加数据
}

5. 渲染通信层 - 事件驱动渲染

设计目标:实现事件系统与渲染系统的解耦通信

typescript 复制代码
// 事件系统发布渲染请求
this.eventEmitter.emit("render:request");

// 渲染系统监听并响应
eventSystem.getEventEmitter().on("render:request", renderCallback);

🔄 五、事件系统与渲染层通信机制

通信架构图

csharp 复制代码
┌─────────────┐    render:request    ┌─────────────┐    ViewInfo    ┌─────────────┐
│  EventSystem│ ──────────────────→ │CoordinateM  │ ────────────→ │SkiaRenderer │
│             │                      │anager       │               │             │
└─────────────┘                      └─────────────┘               └─────────────┘
       │                                     │                            │
   event:processed                    updateViewPosition              Canvas API
       │                                     │                            │
       ↓                                     ↓                            ↓
┌─────────────┐                      ┌─────────────┐               ┌─────────────┐
│UI Components│                      │ ViewManager │               │   Canvas    │
└─────────────┘                      └─────────────┘               └─────────────┘

1. 事件驱动的渲染请求

typescript 复制代码
private async processEvent(event: BaseEvent): Promise<void> {
  try {
    const result = await this.processMiddlewares(event, 0);
    
    // 🎯 更新交互状态
    if (result.newState && result.newState !== this.interactionState) {
      this.setInteractionState(result.newState);
    }
    
    // 🚀 关键:通过EventEmitter解耦通信
    if (result.requestRender) {
      this.eventEmitter.emit("render:request");
    }
    
    // 发布事件处理结果,供其他模块使用
    this.eventEmitter.emit("event:processed", {
      event,
      result,
      state: this.interactionState,
    });
  } catch (error) {
    console.error("❌ 事件处理失败:", error);
  }
}

2. 渲染层监听和响应

typescript 复制代码
// CanvasContainer组件监听渲染请求
useEffect(() => {
  const eventSystem = eventSystemInitializer.getEventSystem();
  
  // 🔧 监听渲染请求事件
  eventSystem.getEventEmitter().on("render:request", renderSkiaLikeUI);
  
  return () => {
    eventSystem.getEventEmitter().off("render:request", renderSkiaLikeUI);
  };
}, []);

const renderSkiaLikeUI = useCallback(() => {
  if (rendererRef.current) {
    // 触发Skia风格渲染
    rendererRef.current.render(
      <>
        <canvas-grid></canvas-grid>
        <canvas-ruler></canvas-ruler>
        <canvas-page></canvas-page>
      </>
    );
  }
}, []);

3. 坐标系统状态同步

typescript 复制代码
export class CoordinateSystemManager {
  // 🎯 事件处理器更新视图状态
  updateViewPosition(deltaX: number, deltaY: number): void {
    const currentView = this.getViewState();
    const updatedView = viewManager.updateTranslation(currentView, deltaX, deltaY);
    this.setViewState(updatedView);
  }
  
  // 🔧 提供坐标转换能力
  screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
    const view = this.getViewState();
    const inverseMatrix = mat3.invert(mat3.create(), view.matrix);
    
    const point = vec2.fromValues(screenX, screenY);
    vec2.transformMat3(point, point, inverseMatrix);
    
    return { x: point[0], y: point[1] };
  }
}

📋 六、实战案例:画布移动事件的完整实现

本节我们实现一下画布平移功能的完整实现来理解整个系统的工作原理。

主要是根据鼠标计算出移动的距离,然后更新视图矩阵,然后重新渲染,因为记录了视图的偏移,我们实际每次绘制的只有屏幕范围内的图像,效果就好像一个可以无限移动的画布

🏗️ 工作流

css 复制代码
用户交互 → CanvasPanHandler → CoordinateSystemManager → ViewManager → SkiaLikeRenderer
    ↓              ↓                    ↓                ↓              ↓
鼠标/键盘事件   事件处理逻辑        坐标变换管理        视图状态更新    Canvas渲染

📝 核心实现:CanvasPanHandler

1. 处理器定义和优先级

typescript 复制代码
export class CanvasPanHandler implements EventHandler {
  name = "canvas-pan";
  priority = 110; // 🎯 比选择工具优先级高,确保平移优先处理
  
  private isPanning = false;
  private lastPanPoint: { x: number; y: number } | null = null;
  private isTemporaryPanMode = false; // 空格键临时模式

  canHandle(event: BaseEvent, state: InteractionState): boolean {
    // 🎯 只有手动工具激活时才处理平移事件
    if (toolStore.getCurrentTool() !== "hand") {
      return false;
    }
    return true;
  }
}

2. 鼠标事件处理

typescript 复制代码
// 鼠标按下 - 开始平移
private handleMouseDown(event: MouseEvent, context: EventContext): EventResult {
  this.isPanning = true;
  this.lastPanPoint = { ...event.mousePoint };
  
  return {
    handled: true,
    newState: "panning", // 🔄 切换到平移状态
    requestRender: false, // 仅状态改变,无需重绘
  };
}

// 鼠标移动 - 计算偏移并更新视图
private handleMouseMove(event: MouseEvent, context: EventContext): EventResult {
  if (!this.isPanning || !this.lastPanPoint) {
    return { handled: true, requestRender: false, newState: "idle" };
  }
  
  // 🎯 计算鼠标移动距离
  const deltaX = event.mousePoint.x - this.lastPanPoint.x;
  const deltaY = event.mousePoint.y - this.lastPanPoint.y;
  
  // 🔧 应用平移偏移量到坐标系统
  coordinateSystemManager.updateViewPosition(deltaX, deltaY);
  
  // 🎯 更新记录点,为下次计算做准备
  this.lastPanPoint = { ...event.mousePoint };
  
  return {
    handled: true,
    newState: "panning",
    requestRender: true, // 🚀 关键:请求重新渲染
  };
}

// 鼠标释放 - 结束平移
private handleMouseUp(event: MouseEvent, context: EventContext): EventResult {
  this.isPanning = false;
  this.lastPanPoint = null;
  return { handled: true, newState: "idle", requestRender: false };
}

3. 键盘事件处理:空格键临时平移

typescript 复制代码
// 空格键按下 - 进入临时平移模式
private handleKeyDown(event: BaseEvent, context: EventContext): EventResult {
  const keyEvent = event as unknown as KeyboardEvent;
  
  if (keyEvent.key === " " || keyEvent.code === "Space") {
    if (!this.isTemporaryPanMode && toolStore.getCurrentTool() !== "hand") {
      this.isTemporaryPanMode = true;
      keyEvent.preventDefault(); // 阻止默认滚动行为
      
      return { handled: true, requestRender: false };
    }
  }
  return { handled: false };
}

// 空格键释放 - 退出临时平移模式  
private handleKeyUp(event: BaseEvent, context: EventContext): EventResult {
  const keyEvent = event as unknown as KeyboardEvent;
  
  if (keyEvent.key === " " || keyEvent.code === "Space") {
    if (this.isTemporaryPanMode) {
      this.isTemporaryPanMode = false;
      this.isPanning = false;
      this.lastPanPoint = null;
      
      return { handled: true, requestRender: false };
    }
  }
  return { handled: false };
}

🌐 坐标系统管理

坐标变换的数学实现

typescript 复制代码
export class CoordinateSystemManager {
  // 🎯 更新视图位置(平移变换)
  updateViewPosition(deltaX: number, deltaY: number): void {
    const currentView = this.getViewState();
    
    // 🔧 通过ViewManager应用变换
    const updatedView = viewManager.updateTranslation(
      currentView,
      deltaX, 
      deltaY
    );
    
    this.setViewState(updatedView); // 同步状态更新
  }
  
  // 🎯 坐标转换:屏幕坐标 ↔ 世界坐标
  screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
    const view = this.getViewState();
    const inverseMatrix = mat3.invert(mat3.create(), view.matrix);
    
    const point = vec2.fromValues(screenX, screenY);
    vec2.transformMat3(point, point, inverseMatrix);
    
    return { x: point[0], y: point[1] };
  }
}

export class ViewManager {
  // 🎯 增量平移变换
  updateTranslation(view: ViewInfo, deltaX: number, deltaY: number): ViewInfo {
    const newMatrix = mat3.clone(view.matrix);
    
    // 🔧 应用平移变换(矩阵乘法)
    mat3.translate(newMatrix, newMatrix, [deltaX, deltaY]);
    
    return { matrix: newMatrix };
  }
}

🎨 渲染系统响应

typescript 复制代码
export class SkiaLikeRenderer {
  performRender(): void {
    // 🎯 获取最新的视图变换矩阵
    const viewState = coordinateSystemManager.getViewState();
    
    // 🔧 应用变换到Canvas上下文
    this.renderApi.setTransform(
      viewState.matrix[0] * this.pixelRatio, // scaleX * pixelRatio
      viewState.matrix[1] * this.pixelRatio, // skewY * pixelRatio  
      viewState.matrix[3] * this.pixelRatio, // skewX * pixelRatio
      viewState.matrix[4] * this.pixelRatio, // scaleY * pixelRatio
      viewState.matrix[6] * this.pixelRatio, // translateX * pixelRatio
      viewState.matrix[7] * this.pixelRatio  // translateY * pixelRatio
    );
    
    // 🎨 重新绘制所有元素
    this.rootContainer.render(renderContext);
  }
}

📊 完整的事件流程

scss 复制代码
用户拖拽鼠标
     ↓
DOM mousedown事件 → EventSystem.handleDOMEvent()
     ↓  
EventFactory.createMouseEvent() → 标准化事件对象
     ↓
EventSystem.processEvent() → 中间件处理
     ↓
CanvasPanHandler.canHandle() → 检查是否可处理
     ↓
CanvasPanHandler.handleMouseDown() → 开始平移状态
     ↓
返回 { handled: true, newState: "panning" }
     ↓
EventSystem 更新交互状态
     ↓
用户移动鼠标...
     ↓
DOM mousemove事件 → CanvasPanHandler.handleMouseMove()
     ↓
计算位移增量 (deltaX, deltaY)
     ↓
coordinateSystemManager.updateViewPosition()
     ↓
viewManager.updateTranslation() → 更新变换矩阵
     ↓
返回 { handled: true, requestRender: true }
     ↓
eventEmitter.emit("render:request")
     ↓
SkiaLikeRenderer.performRender() → Canvas重绘
     ↓
视觉反馈完成 ✨

完整代码

js 复制代码
import {
  EventHandler,
  EventResult,
  EventContext,
  BaseEvent,
  MouseEvent,
  KeyboardEvent,
  InteractionState,
} from "../types";
import { toolStore } from "../../store/ToolStore";
import { coordinateSystemManager } from "../../manage/CoordinateSystemManager";

/**
 * 画布移动处理器
 * 处理手动工具的画布拖拽移动功能
 */
export class CanvasPanHandler implements EventHandler {
  name = "canvas-pan";
  priority = 110; // 比选择工具优先级高

  private isPanning = false;
  private lastPanPoint: { x: number; y: number } | null = null;

  // 临时平移模式(按住空格键时启用)
  private isTemporaryPanMode = false;

  canHandle(event: BaseEvent, state: InteractionState): boolean {
    if (toolStore.getCurrentTool() !== "hand") {
      return false;
    }
    return true;
  }

  async handle(event: BaseEvent, context: EventContext): Promise<EventResult> {
    const mouseEvent = event as MouseEvent;

    switch (event.type) {
      case "mouse.down":
        return this.handleMouseDown(mouseEvent, context);
      case "mouse.move":
        return this.handleMouseMove(mouseEvent, context);
      case "mouse.up":
        return this.handleMouseUp(mouseEvent, context);
      case "key.down":
        return this.handleKeyDown(event, context);
      case "key.up":
        return this.handleKeyUp(event, context);
      default:
        return { handled: false };
    }
  }

  private handleMouseDown(
    event: MouseEvent,
    context: EventContext
  ): EventResult {
    this.isPanning = true;
    this.lastPanPoint = { ...event.mousePoint };

    return {
      handled: true,
      newState: "panning",
      requestRender: false,
    };
  }

  private handleMouseMove(
    event: MouseEvent,
    context: EventContext
  ): EventResult {
    if (!this.isPanning || !this.lastPanPoint) {
      return {
        handled: true,
        requestRender: false,
        newState: "idle", // 明确设置为idle状态
      };
    }

    // 计算移动距离
    const deltaX = event.mousePoint.x - this.lastPanPoint.x;
    const deltaY = event.mousePoint.y - this.lastPanPoint.y;

    // 应用平移偏移量到坐标系统
    coordinateSystemManager.updateViewPosition(deltaX, deltaY);

    // 更新最后的平移点
    this.lastPanPoint = { ...event.mousePoint };

    return {
      handled: true,
      newState: "panning",
      requestRender: true,
    };
  }

  private handleMouseUp(event: MouseEvent, context: EventContext): EventResult {
    this.isPanning = false;
    this.lastPanPoint = null;
    return {
      handled: true,
      newState: "idle",
      requestRender: false,
    };
  }

  private handleKeyDown(event: BaseEvent, context: EventContext): EventResult {
    const keyEvent = event as unknown as KeyboardEvent;

    // 空格键启用临时平移模式
    if (keyEvent.key === " " || keyEvent.code === "Space") {
      if (!this.isTemporaryPanMode && toolStore.getCurrentTool() !== "hand") {
        this.isTemporaryPanMode = true;
        keyEvent.preventDefault();

        return {
          handled: true,
          requestRender: false,
        };
      }
    }

    return { handled: false };
  }

  private handleKeyUp(event: BaseEvent, context: EventContext): EventResult {
    const keyEvent = event as unknown as KeyboardEvent;

    console.log(
      "⌨️ CanvasPanHandler - 处理按键释放:",
      keyEvent.key,
      keyEvent.code
    );

    // 释放空格键,退出临时平移模式
    if (keyEvent.key === " " || keyEvent.code === "Space") {
      if (this.isTemporaryPanMode) {
        this.isTemporaryPanMode = false;
        this.isPanning = false;
        this.lastPanPoint = null;

        return {
          handled: true,
          requestRender: false,
        };
      }
    }

    return { handled: false };
  }
}

🎯 总结与展望

下一篇我会整理下标尺和网格的绘制逻辑

相关推荐
真的想不出名儿4 小时前
登录前验证码校验实现
java·前端·python
小高0074 小时前
前端如何优雅地生成唯一标识?——一份跨环境 UUID 工具函数的封装与实战
前端·javascript·vue.js
云舟吖4 小时前
Playwright的元素定位器
前端
我是日安4 小时前
从零到一打造 Vue3 响应式系统 Day 24 - Watch:Options
前端·javascript·vue.js
浅浅的学一下4 小时前
实现在富文本中直接Ctrl+C复制图片并自动上传,并支持HTML格式的图片的复制
前端
wifi歪f4 小时前
🎨 探究Function Calling 和 MCP 的奥秘
前端·ai编程·mcp
BrendanDash4 小时前
React 19.2 已发布,现已上线 npm!
前端·react.js
sheji34164 小时前
【开题答辩全过程】以 Web数据挖掘在电子商务中的应用研究为例,包含答辩的问题和答案
前端·人工智能·数据挖掘