普通视图

发现新文章,点击刷新页面。
昨天以前首页

HTML Canvas API 技术简述与关系性指南

作者 右子
2025年10月8日 10:00

HTML Canvas API 技术简述与兼容性指南

大家好,我是右子。
一名喜爱编程的程序员。

一、Canvas API 的作用

HTML5 引入的 <canvas\> 元素提供了一块用 JavaScript 动态绘制图形的画布,可用于绘制图表、游戏界面、动画和图像处理。Canvas API 是构建在 <canvas\> 元素之上的 2D 绘图接口,早在 2015 年就已被各大浏览器广泛实现。Safari 官方文档指出,Canvas “在 Safari 2.0 及更高版本以及其他大多数现代浏览器中都可用”。

Canvas API 工作于浏览器的主线程,通过 getContext('2d') 获得绘图上下文,然后使用各种方法绘制形状、路径、文本或图像。例如,通过 getElementById() 取得 canvas 元素,调用 getContext('2d') 即可获取 2D 渲染上下文。

<canvas id="myCanvas" width="300" height="200">
  您的浏览器不支持 canvas。
</canvas>

<script>
  const canvas = document.getElementById('myCanvas');
  const ctx = canvas.getContext('2d');
  // 设置填充颜色为绿色,绘制矩形
  ctx.fillStyle = 'green';
  ctx.fillRect(10, 10, 150, 100);
</script>

二、核心 API 用法

Canvas 2D 上下文提供了丰富的方法来绘制各种元素。以下是一些常用接口及其用途:

方法/属性 简介 示例代码
fillRect(x, y, w, h) 绘制填充矩形,参数为左上角坐标和宽高。 ctx.fillRect(10, 10, 100, 50);
strokeRect(x, y, w, h) 绘制矩形轮廓。 ctx.strokeRect(10, 10, 100, 50);
clearRect(x, y, w, h) 擦除矩形区域的像素。 ctx.clearRect(0, 0, canvas.width, canvas.height);
beginPath() / closePath() 开始/结束一个路径。通常用于绘制复杂图形。 ctx.beginPath(); ctx.moveTo(50, 50); ctx.lineTo(150, 50); ctx.closePath(); ctx.stroke();
moveTo(x, y) / lineTo(x, y) 设置路径的起点并添加直线段。 同上
arc(x, y, r, start, end) 绘制圆弧或圆。 ctx.arc(100, 75, 50, 0, 2*Math.PI);
ellipse() 绘制椭圆,自 2016 年起被主流浏览器支持。 ctx.ellipse(100, 75, 60, 40, 0, 0, 2*Math.PI);
fill() / stroke() 绘制路径的填充或轮廓。 ctx.fill(); or ctx.stroke();
fillText(text, x, y) 在指定位置绘制文本。 ctx.font = '20px sans-serif'; ctx.fillText('Hello', 50, 50);
drawImage(img, x, y) 将图像绘制到 canvas 上。支持 <img><video> 和另一张 canvas 等。 ctx.drawImage(imgElement, 0, 0);

💡 使用建议:canvas 默认大小为 300×150 像素,最好在 HTML 中显式设置 widthheight 属性。请务必在 <canvas> 标签中放置备用内容,用于旧浏览器或禁用 JavaScript 的环境

三、浏览器兼容性

1. 基础 Canvas API

MDN 的兼容性基准显示,Canvas API 已在现代浏览器中普遍可用,Chrome、Edge、Firefox 和 Safari 均支持此接口。Apple 文档同样强调,Canvas 在 Mac 和 Windows 上的 Safari 2.0 及更高版本、所有 iOS 版本以及大多数其他现代浏览器中均可用因此,只要使用最新或倒数第二个版本的主流浏览器,基本的 Canvas 绘图功能都能正常工作。

下表列出了各主流浏览器对 Canvas 元素和 2D API 的支持情况(表示从哪个版本开始提供支持;“✔︎” 表示全量支持):

浏览器 支持 Canvas 元素 备注
Chrome ✔︎(早期版本即支持) 全面支持 Canvas API;支持高分辨率模式等。
Edge ✔︎(Edge 12 及更高) 自从 Edge 采用 Chromium 内核后与 Chrome 相同。
Firefox ✔︎(1.5+) 全面支持 Canvas API,包括 ellipse() 等新方法。
Safari ✔︎(2.0+ 桌面,所有 iOS 版本) 官方文档确认从 Safari 2.0 起支持
Opera ✔︎(9+) 基于 Chromium,兼容性与 Chrome 接近。
旧版 IE 部分支持 IE 9 以上支持基本 Canvas,IE 8 及以前完全不支持,需要 Flash 或 VML 替代。

2. 高级特性

部分较新的特性存在差异,开发时需要注意:

  • OffscreenCanvas 允许在 Web Worker 中渲染 Canvas 内容以提升性能。该特性于 2023 年起在 Chrome 和 Firefox 中提供,Safari 仍未实现因此在使用 OffscreenCanvas 时应提供回退方案,例如检测 window.OffscreenCanvas 并在不支持的浏览器中使用普通 canvas 结合 requestAnimationFrame() 绘制。

  • Path2Dellipse() 等方法在 IE 中完全不支持,且在极老版本的移动浏览器中也可能缺失。开发时可使用 polyfill 或用基本方法组合绘制代替。

  • WebGL (通过 getContext('webgl'))提供硬件加速的 2D/3D 渲染,兼容性良好但仍需要留意 Safari 旧版本、低端移动设备或安全设置导致的禁用情况。

四、兼容性技巧与最佳实践

  1. 提供后备内容 :在 <canvas\> 标签内添加文本或图像作为 fallback,当浏览器不支持 Canvas 或用户禁用 JavaScript 时显示

  2. 检测支持并降级 :通过检查相关属性来判断功能是否可用。例如:

    if (window.OffscreenCanvas) {
      // 使用 OffscreenCanvas
    } else {
      // 回退到普通 canvas
    }
    
  3. 避免依赖特定实现 :不同浏览器的 Canvas 实现性能差异较大。尽量写兼容标准的方法,避免使用非标准属性如 moz-opaque(已弃用)等。

  4. 合理设置大小与分辨率 :高分辨率屏幕(Retina 显示)上需要根据 devicePixelRatio 调整画布的实际像素,以免图形模糊。

  5. 使用请求动画帧 :绘制动画时使用 requestAnimationFrame() 而不是 setInterval()setTimeout(),可让浏览器在非活跃标签页自动降低帧率,节省资源。

五、结语

Canvas API 作为现代 Web 前端不可或缺的图形接口,已经在主流桌面和移动浏览器上得到充分支持。
只要注意老浏览器提供回退内容,合理检测新特性并降级实现,就可以在 Canvas 上创造丰富的图形和动画效果。
针对 Safari 暂未支持 OffscreenCanvas 的情况,也可以利用普通 Canvas 结合 Web Worker 的替代方案或优化算法来提升性能。
通过本文开发者可以快速上手 Canvas 2D 接口,实现数据可视化、游戏、动画等多种场景的实践。

Canvas 高性能K线图的架构方案

作者 VincentFHR
2025年10月4日 04:42

前言

证券行业,最难的前端组件,也就是k线图了。
指标还可以添加、功能还可以扩展, 但思路要清晰。
作为一个从证券行业毕业的前端从业者,
我想分享下自己的项目经验。

1、H5 K线图,支持无限左右滑动、样式可随意定制;
2、纯canvas制作,不借助任何第三方图表库;
3、阅读本文,需要有 canvas 基础知识;

滑动K线图组件    Github Page 预览地址

股票详情页源码    Github Page 预览地址

注意:以上的 demo 还有一些 bug, 没时间修复, 预览地址是直接在 github 上部署的, 所以最好通过 vpn 科学上网, 否则可能访问不了。另外, 上面的股票详情页, 还没有做自适应,等我有时间再改。

一、先看最终的效果

1、GIF动图如下

gif222.gifgif.gif

2、支持样式自定义

用可以屏幕取色器,获取东方财富的配色 codeinword.com/eyedropper

图一、图二,是参考东方财富黑白皮肤的配色, 图三是参考腾讯自选股的配色。

q1.pngq2.pngq3.png

二、canvas 注意事项

1、整数坐标,会导致模糊

canvas 在画线段, 通常会出现以下代码:

cxt.moveTo(x1, y1);
cxt.lineTo(x2, y2);
cxt.lineWidth = 1;
cxt.stroke();

假设上面的两个点是(1,10)和(5,10),那么画出来的实际上是一条横线,
理论上横线的粗度是1px,且该横线被 y=10 切成两半,
上半部分粗度是 0.5px, 下半部分粗度也是 0.5px,
这样横线的整体粗度才会是 1px。

但是 canvas 不是这样处理的, canvas 默认线条会与整数对齐,
也就是横线的上部分不会是 y=9.5px, 而是 y=9px;
横线的下半部分也不是 y=10.5px, 而是 y=11px;
从而横线的粗度看起来不是1px,而是2px。

并且由于粗度被拉伸,颜色也会被淡化,那怎么解决这个问题呢?

处理方式也很简单, 通过 cxt.translate(0.5, 0.5) 将坐标往下移动 0.5 个像素,
然后接下来的所有点, 都保证是整数即可, 这样就能保证不会被拉伸。

典型的代码如下:

cxt.translate(0.5, 0.5);
cxt.moveTo(Math.floor(x1), Math.floor(y1));
cxt.lineTo(Math.floor(x2), Math.floor(y2));
cxt.lineWidth = 1;
cxt.stroke();

在我的代码中, 也体现了类似的处理。

2、如何处理高像素比带来的模糊

设备像素比越高,理论上应该越清晰,因为原来用一个小方块来渲染1px, 现在用2个小方块来渲染,应该更清晰才对,但是canvas不是这样的。

例如,通过js获取父容器 div 的宽度是 width, 这时候如果设置 canvas.width = width,在设备像素比为2的时候, canvas 画出来的宽度为css对应宽度的一半, 如果强制通过 css 将 canvas 宽度设置为 width, 则 canvas 会被拉长一倍, 导致出现锯齿模糊。

注意了吗?上面所说的 canvas.width=width 与 css 设置的 #canvas { width: width } 起到的效果是不一样的。不要随便通过 css 去设置 canvas 的宽高, 容易被拉伸变形或者导致模糊。

通用的处理方式是:

//初始化高清Canvas
function initHDCanvas() {
  const rect = hdCanvas.getBoundingClientRect();
  
  //设置Canvas内部尺寸为显示尺寸乘以设备像素比
  const dpr = window.devicePixelRatio || 1;
  hdCanvas.width = rect.width * dpr;
  hdCanvas.height = rect.height * dpr;
  
  //设置Canvas显示尺寸保持不变
  hdCanvas.style.width = rect.width + 'px';
  hdCanvas.style.height = rect.height + 'px';
  
  //获取上下文并缩放
  const ctx = hdCanvas.getContext('2d');
  ctx.scale(dpr, dpr);
}

三、样式配置

为了方便样式自定义, 我独立出一个默认的配置对象 defaultKlineConfig, 参数的含义如下图所示,其实下图这个风格的标注, 是通过 excalidraw 这个软件画的, 也是 canvas 做的开源软件, 可见 canvas 在前端可视化领域的重要性, 这个扯远了,打住。

333.png

如上图, 整个canvas 画板, 分成 5 部分,
每一部分的高度, 都可以设置,
其中主图和副图的高度,是通过比例来计算的:
mainChartHeight = restHeight * mainChartHeightPercent
其中,restHeigh 是画板总高度 height 减去其他几部分计算的, 如下:
restHeight = height - lineMessageHeight - tradeMessageHeight - xLabelHeight

十字交叉线的颜色, X轴 与 Y轴 的 tooltip 背景色、字体大小的参数如下:

7777.png

四、均线计算

从上面的图可以看出, 需要画 5日均线、10日均线、20日均线, 成交量快线(10日)、成交量慢线(20日) 但是, 接口没有给出当日的均线值, 需要自己计算。

5日均线 = (过去4个成交日的收盘价总和 + 今日收盘价)/ 5

10日均线 = (过去9个成交日的收盘价总和 + 今日收盘价)/ 10

20日均线 = (过去19个成交日的收盘价总和 + 今日收盘价)/ 20

成交量快线 = (过去9日成交量 + 今日成交量)/ 10

成交量慢线 = (过去19日成交量 + 今日成交量)/ 20

所以, 当获取 lmt(一屏的蜡烛图个数)个数据时, 为了计算均线, 需要至少将前 19 个(我的代码写20)数据都获取到。当前一个均线已经获取到, 下一个均线就不需要再累加20个值再得平均数, 可以省一点计算:

今日20日均线值 = (昨日均线值 * 20 - 前面第20个的收盘价 + 今日收盘价)/ 20;

五、分层渲染

为了减少重绘,提高性能,可以将K线图做分层渲染。那分几层合适?我认为是三层。

  1. 第一层, 不动层
  2. 第二层,变动层
  3. 第三层,交互层

不动层

首先, 网格是固定的, 也就是说,当页面拖拽、或者长按出现十字交叉的时候,底部的网格线是不变的,如果每次拖拽,都需要重绘网格,那这个其实是没有必要的开销,可以将网格放在最底层,一次性绘制后,就不要再重绘。

变动层

由于拖拽的时候,蜡烛柱体,均线,Y轴刻度, X轴刻度, 都需要重绘, 这一块是无法改变的事实, 所以, 变动层放在中间层,也是最繁忙的一层,并且该层不响应触摸事件,触摸事件交给交互层。

交互层

交互层监听触摸事件:当页面快速滑动, 则响应拖拽事件, 即K线图的时间线会左右滑动;当用户长按之后才滑动, 则出现十字交叉浮层。

交互层的好处是, 当响应十字交叉浮层时, 只需要绘制横线、竖线、对应X轴和Y轴的值,而不需要重绘蜡烛柱体和均线, 可以减少重绘,最大程度减少渲染压力。

六、基础几何绘制

网格线

首先计算出主图的高度 this.mainChartHeight, 将主图从上到下等分为4部分,再在左右两边画出竖线,形成主图的网格,副图是成交量图, 只需画一个矩形边框即可,用 strokeRect 即可画出。

//画出网格线
  private drawGridLine() {
    //获取配置参数
    const { gridColor, lineMessageHeight, xLabelHeight, width, height } = this.config;
    //画出K线图的5条横线
    const split = this.mainChartHeight / 4;
    this.canvasCxt.beginPath();
    this.canvasCxt.lineWidth = 0.5;
    this.canvasCxt.strokeStyle = gridColor;
    for (let i = 0; i <= 4; i++) {
      const splitHeight = Math.floor(split * i) + lineMessageHeight!;
      this.drawLine(0, splitHeight, width, splitHeight);
    }
    //画出K线图的2条竖线
    this.drawLine(0, lineMessageHeight!, 0, lineMessageHeight! + this.mainChartHeight);
    this.drawLine(width, lineMessageHeight!, width, lineMessageHeight! + this.mainChartHeight);
    //画出成交量的矩形
    this.canvasCxt.strokeRect(
      0,
      height - xLabelHeight! - this.subChartHeight,
      width,
      this.subChartHeight,
    );
  }
  
  //画出两个点形成的直线
  private drawLine(x1: number, y1: number, x2: number, y2: number) {
    this.canvasCxt.moveTo(x1, y1);
    this.canvasCxt.lineTo(x2, y2);
    this.canvasCxt.stroke();
  }

画各类均线

1、首先计算出一屏的股价最大值 max , 股价最小值 min ,成交量最大值 maxAmount。

2、当某一个点的均线为 value, 根据最大值、最小值、索引index, 计算出坐标点(x, y), 画均线的时候, 第一个点用 moveTo(x0, y0),其他点用 lineTo(xn yn), 最后 stroke 连起来即可。

3、当然, 每一条线设置下颜色, 即 stokeStyle。

  //画出各类均线
  private drawLines(max: number, min: number, maxAmount: number) {
    //将宽度分成n个小区间, 一个小区间画一个蜡烛, 每个区间的宽度是 splitW
    const splitW = this.config.width / this.config.lmt!;
    //画一下5日均线
    this.canvasCxt.beginPath();
    this.canvasCxt.strokeStyle = this.config.ma5Color;
    this.canvasCxt.lineWidth = 1;
    let isTheFirstItem = true;
    for (
      let i = this.startIndex;
      i < this.arrayList.length && i < this.startIndex + this.config.lmt!;
      i++
    ) {
      const index = i - this.startIndex;
      let value = this.arrayList[i].ju5;
      if (value === 0) {
        continue;
      }
      const x = Math.floor(index * splitW + 0.5 * splitW);
      const y = Math.floor(
        ((max - value) / (max - min)) * this.mainChartHeight + this.config.lineMessageHeight!,
      );
      if (isTheFirstItem) {
        this.canvasCxt.moveTo(x, y);
        isTheFirstItem = false;
      } else {
        this.canvasCxt.lineTo(x, y);
      }
    }
    this.canvasCxt.stroke();
  }

画出蜡烛柱体

666.png999.png

当收盘价大于等于开盘价, 选用上面左边红色的样式; 当收盘价小于开盘价, 选用上面右边绿色的样式。

以红色蜡烛为例, 最高点 A(x0, y0),最低点是 B(x1, y1),
高度 height、宽度 width 都是相对于坐标轴的,
红色矩形左上角的顶点是 D(x, y)。

为了画出红色蜡烛, 先后顺序别搞混:

  1. AB 这条竖线,通过 moveTo,lineTo 画出来;
  2. 定义一个矩形 cxt.rect(x, y, width, heigth);
  3. 通过 fill 填充白色背景, 同时覆盖后面的红色竖线;
  4. 再通过 stroke 描出红色边框

按照上面这个顺序, 竖线会被覆盖掉,同时,矩形内部的白色填充不会挤压矩形的红色边框, 如果先 stroke 再 fill,容易出现白色填充覆盖红色边框,矩形可能会变模糊,或者使得红色变淡,极其不友好,所以按照我上面的顺序,可以减少不必要的麻烦。

画出文字

canvas 画出文字, 典型的代码如下

 this.canvasCxt.beginPath();
    this.canvasCxt.font = `${this.config.yLabelFontSize}px "Segoe UI", Arial, sans-serif`;
    this.canvasCxt.textBaseline = 'alphabetic';
    this.canvasCxt.fillStyle = this.config.yLabelColor;

注意textBaseline 默认对齐方式是 alphabetic, 但 middle 往往更好用, 能实现垂直居中,但我发现垂直居中也不是很居中,所以会特意加减1、2个像素;

当然还有个textAlign, 能实现水平对齐方式, 左右对齐都可以, 例如上图最左、最右的时间标签。

七、交互设计

根据上面的GIF动图, 可以知道, 本次做的移动端 K 线图, 最重要的两个交互是:

  1. 快速拖拽,K线图随时间轴左右滑动
  2. 长按滑动,出现十字交叉tooltip

上面的交互,其实是比较复杂的,所以需要先设计一个简单的数据结构:

  1. 首先页面存放一个列表 arrayList
  2. 保存一个数字标识 startIndex,表示当前屏幕从 startIndex 开始画蜡烛图

当用户往右快速拖拽时, startIndex 根据用户拖拽的距离, 适当变小; 当用户往左快速拖拽时, startIndex 根据用户拖拽的距离, 适当变大。

那 arrayList 到底多长合适, 因为股票可能有十几年的数据, 甚至上百年的数据, 我不能一次性拉取这个股票的所有数据吧?

当然,站在软件性能、消耗等角度,也不应该一次性拉取所有的数据, 我的答案是 arraylist 最多保存5屏的数据量,用户看到的屏幕, 应该是接近中间这一屏,也就是第3屏的数据, 左右两边各保存2屏数据,这样,用户拖拽的时候,可以比较流畅,而不是每次拖拽都要等拉取数据再去渲染。

那什么时候拉取新的数据呢? 用户触摸完后,当startIndex左边的数据少于2屏,开始拉取左边的数据; 用户触摸完后,当startIndex右边的数据少于2屏,开始拉取右边的数据;

那如果用户一直往右拖拽, 是不是就一直往左边添加数据, 这个 arraylist 是不是会变得很长?

当然不是,例如,当我往 arraylist 的左边添加数据的时候,startIndex 也会跟着变动, 因为用户看到的第一条柱体,在 arraylist 的索引已经变了。当我往 arraylist 的某一边添加数据后, arraylist 的另一边如果数据超过 2 屏, 要适当裁掉一些数据, 这样 arraylist 的总数, 始终保持在 5 屏左右,就不会占用太多的存放空间。

总体思想是, 从 startIndex 开始渲染屏幕的第一条柱体, 当前屏幕的左右两边, 都预留2屏数据,防止用户拖拽频繁调用接口, 导致卡顿; 同时也控制了 arraylist 的长度, 这是虚拟列表的变形,这样设计,可以做一个 高性能 的k线图。

八、触摸事件解耦

根据上面的分析:

  1. 快速拖拽, K线图左右移动
  2. 长按再滑动, 出现十字交叉tooltip

以上两种拖拽,都在 touchmove 事件中触发, 那怎么区分开呢? 典型的 touchstart、 touchmove 、 touchend 解耦如下:

let timer = null;
let startX = 0;
let startY = 0;
let isLongPress = false;

canvas.addEventListener('touchstart', (e) => {
    startX = e.touches[0].clientX;
    startY = e.touches[0].clientY;
    isLongPress = false;
    
    timer = setTimeout(() => {
        isLongPress = true;
        // 显示十字光标hover
        showCrossHair(e);
    }, 500);
});

canvas.addEventListener('touchmove', (e) => {
    if (isLongPress) {
        // 长按移动时更新十字光标位置
        updateCrossHair(e);
    } else {
        // 快按拖动时移动K线图
        clearTimeout(timer);
        moveKLineChart(e);
    }
});

canvas.addEventListener('touchend', () => {
    clearTimeout(timer);
    if (isLongPress) {
        // 长按结束隐藏十字光标
        hideCrossHair();
    }
    isLongPress = false;
});

// 关闭十字光标
function hideCrossHair() {
    // 隐藏逻辑
}

根据上面的框架, 再详细补充下代码就可以了。 然后再在 touchend 事件中, 新增或减少 arraylist 的数据量。

九、性能优化

其实, 做到上面的设计,性能已经很好了,可以监控帧率来看下滑动的流畅程度。

总结下我做了什么操作,来提高整体的性能:

1、分层渲染

将K线图画在3个canvas上。

  1. 不动层只需要绘画一次;
  2. 变动层根据需要而变动;
  3. 交互层独立出来,不会影响其它层,变动层的大量蜡烛柱体、均线等也不会受交互层的影响

2、离屏渲染

当需要在K线上标注一些icon时, 这些 icon 可以先离屏渲染, 需要的时候, 再copy到变动层对应的位置,这样比临时抱佛脚去画,要省很多时间,也能提高新能。

3、设置数据缓冲区

就是屏幕只渲染一屏数据, 但是在当前屏的左右两边,各缓存了2屏数据, 超过5屏数据的时候,及时裁掉多余的数据, 这样arraylist的数据量始终保持在5屏, 控制了数据量,有效的控制了占用空间。

4、节流防抖

touchmove 会很频繁触发, 可通过节流来控制,减少不必要的渲染。

十、部署到GitHub Pages

1、安装gh-pages包

npm install --save-dev gh-pages

2、package.json 添加如下配置

注意, Stock 这个需要对应github的仓库名

{
  "homepage": "https://fhrddx.github.io/Stock",
  "scripts": {
    "predeploy": "npm run build",
    "deploy": "gh-pages -d build"
  }
}

3、运行部署命令

npm run build
npm run deploy

1.png

最后, 访问上面的链接(注意,在国内可能要开vpn)

fhrddx.github.io/Stock/

这样, github pages 部署成功, 访问上面链接, 可以看到如下效果。

2.png

github page 的部署需要将仓库设置为 public, 这个我挺反感的, 可以用 vercel 部署, 也就是将 github 账号与 vercel 关联起来, 项目的 package.js 的 homepage 设置为 “.” , 然后 vercel 可以点击一下, 一键部署, 常见的命令行如下:

# 1. 安装 Vercel CLI
npm install -g vercel

# 2. 在项目根目录登录 Vercel
vercel login

# 3. 部署项目
npm run build
vercel --prod

# 或者直接部署 build 文件夹
vercel --prod --build

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

2025年10月3日 10:43

发布日期: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-grid>
        <canvas-ruler></canvas-ruler>
        <canvas-page></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 ViewManager {
  // 🎯 增量平移变换
  updateTranslation(view: ViewInfo, deltaX: number, deltaY: number): ViewInfo {
    const newMatrix = mat3.clone(view.matrix);
    
    // 🔧 应用平移变换(矩阵乘法)
    mat3.translate(newMatrix, newMatrix, [deltaX, deltaY]);
    
    return { matrix: newMatrix };
  }
}

🎨 渲染系统响应

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", // 明确设置为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 };
  }
}

🎯 总结与展望

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

❌
❌