🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

【圖形編輯器架構】:編輯器的 Canvas 分層事件系統

image

發布日期:2025年10月3日 | 預計閱讀時間:25 分鐘

最近在重構編輯器 demo 的時候,我重新梳理了事件層的實現。在節點層 → 渲染層之後,本篇重點切換到互動事件系統,也就是 Canvas 如何處理複雜互動,如何設計一個類似 Figma 的獨立事件架構。


🧑‍💻 寫在開頭

點讚 + 收藏 = 支持原創 🤣🤣🤣

上一篇文章我們聊了 數據層與渲染層的綁定機制,今天繼續推進 —— 把視角放到事件層。你會看到一個清晰的五層架構設計,從 DOM 原生事件到業務邏輯處理器,再到渲染層通訊,完整展示現代 Canvas 應用的事件流轉機制。

本篇你將學到:

  • 為什麼 React 事件系統不適合高複雜度的 Canvas 應用
  • Figma 式事件系統的五層架構
  • 中間件、處理器、狀態機的完整設計模式
  • CanvasPanHandler(畫布平移)的完整落地實現
  • 性能優化策略:按需渲染、RAF 批處理、記憶體管理

🍎 系列背景 & 延續

這個系列文章主要記錄了編輯器從 0 到 1 的實現細節:

  • 節點樹架構
  • 渲染層對接 Reconciler + Canvas
  • 數據層與渲染層綁定機制
  • 事件系統(本文重點)

之前的文章:

今天我們聊第 4 篇:事件系統設計

實現效果

2025-10-03 00.21.38.gif

🎯 一、引言:為什麼需要獨立的事件系統?

在構建複雜的 Canvas 應用(如 Figma、Sketch 等設計工具)時,傳統的 React 事件或者原生的 DOM 事件面臨著嚴峻的挑戰。隨著應用複雜度的增加,我們需要處理更精細的用戶互動、更複雜的狀態管理,以及更高效的渲染性能。

比如 mousedown 事件,可能處理點擊創建、鉛筆繪製、拖拽事件、畫布平移等,每種型態的事件可能會處理各種的業務邏輯,如何分發,如果處理不好,很容易就會混亂。

傳統方案的局限性

// 傳統 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 互動邏輯清晰且可擴展。整個實現流程可分為五大層:

用戶互動 → DOM 原生事件 → 事件工廠層 → 核心管理層 → 中間件層 → 處理器層 → 渲染通信 → Canvas 渲染

具體流程:

  1. DOM 事件層

    • 捕獲鼠標、觸摸、鍵盤事件
    • 阻止預設瀏覽器行為(滾動、右鍵選單等)
    • 將事件傳入事件系統
  2. 事件工廠層

    • 將原生事件轉換為統一的應用事件物件(BaseEvent)
    • 增加時間戳、坐標、狀態等元信息
    • 保留對原生事件的控制能力(preventDefault、stopPropagation)
  3. 核心管理層(EventSystem)

    • 單例模式管理全局事件
    • 狀態機管理互動狀態(idle、hover、dragging、panning 等)
    • 按優先級分發事件,支持責任鏈模式和短路機制
  4. 中間件層

    • 洋蔥模型處理事件
    • 可插拔中間件支持日誌、權限、快取、性能監控等
    • 可在前置或後置階段處理事件
  5. 處理器層(EventHandler)

    • 封裝具體業務邏輯(平移、選擇、繪製等)
    • 根據當前工具和互動狀態決定是否處理事件
    • 返回處理結果:是否 handled、是否請求渲染、互動狀態更新
  6. 渲染通信層

    • 事件處理器通過 EventEmitter 發布渲染請求
    • 渲染系統監聽並響應,實現按需重繪
    • 坐標系統狀態同步,螢幕坐標 ↔ 世界坐標轉換

🔄 三、完整事件流程示意

用戶拖曳鼠標
     ↓
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. 事件工廠層 - 標準化轉換

定義統一的事件物件

// 基礎事件
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 事件轉換為應用層統一的事件物件

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 上的鼠標、觸摸、鍵盤事件統一接入我們設計的事件體系。

/**
 * 綁定 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. 核心管理層 - 事件分發調度

設計目標:提供統一的事件管理和分發機制

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. 中間件層 - 可插拔處理

設計目標:提供橫切關注點的處理能力

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. 處理器層 - 業務邏輯

設計目標:封裝具體的業務處理邏輯

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. 渲染通信層 - 事件驅動渲染

設計目標:實現事件系統與渲染系統的解耦通信

// 事件系統發布渲染請求
this.eventEmitter.emit("render:request");

// 渲染系統監聽並響應
eventSystem.getEventEmitter().on("render:request", renderCallback);

🔄 五、事件系統與渲染層通信機制

通信架構圖

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

1. 事件驅動的渲染請求

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. 渲染層監聽和響應

// 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-ruler />
        <canvas-page />
      </>
    );
  }
}, []);

3. 坐標系統狀態同步

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] };
  }
}

📋 六、實戰案例:畫布移動事件的完整實現

本節我們實現一下畫布平移功能的完整實現來理解整個系統的工作原理。

主要是根據鼠標計算出移動的距離,然後更新視圖矩陣,再重新渲染,因為記錄了視圖的偏移,我們實際每次繪製的只有螢幕範圍內的圖像,效果就好像一個可以無限移動的畫布。

🏗️ 工作流

用戶互動 → CanvasPanHandler → CoordinateSystemManager → ViewManager → SkiaLikeRenderer
    ↓              ↓                    ↓                ↓              ↓
鼠標/鍵盤事件   事件處理邏輯        坐標變換管理        視圖狀態更新    Canvas渲染

📝 核心實現:CanvasPanHandler

1. 處理器定義和優先級

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. 鼠標事件處理

// 鼠標按下 - 開始平移
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. 鍵盤事件處理:空格鍵臨時平移

// 空格鍵按下 - 進入臨時平移模式
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 };
}

🌐 坐標系統管理

坐標變換的數學實現

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 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);
  }
}

📊 完整的事件流程

用戶拖曳鼠標
     ↓
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重繪
     ↓
視覺反饋完成 ✨

完整代碼

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" };
    }

    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;

    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 };
  }
}

🎯 總結與展望

下一篇我會整理下標尺和網格的繪製邏輯。


原文出處:https://juejin.cn/post/7556259188487028751


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝20   💬9   ❤️5
708
🥈
我愛JS
📝4   💬14   ❤️7
254
🥉
御魂
💬1  
3
#5
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付