【canvas】react+canvas实现无限画布、鼠标为中心缩放、标尺、移动画布

2,928 阅读10分钟

🧑‍💻 写在开头

点赞 + 收藏 === 学会 🤣🤣🤣

本篇是图形学专栏的开篇第一篇,目标是通过实现一个简单的 2D 编辑器博客,来巩固和分享一些图形学的知识。将平时写的demo项目展示在无限画布中。感兴趣的话大家可以收藏关注一下,这是件长期且有趣的事情。

🥑 你能学到什么?

希望你在阅读本文后不会觉得浪费了时间。如果你跟着学习,你将会掌握:

  • Canvas 基本使用
  • 图形学中的简单矩阵原理
  • 如何在 React 中将 Canvas 作为渲染引擎
  • 如何实现拖拽、缩放画布
  • 如何实现标尺
  • 如何实现无限画布和坐标系转换

专栏预告

  1. 实现无限画布、鼠标为中心缩放、标尺、移动画布 ✅
  2. 节点树的实现、创建维护、设计绘制节点
  3. 实现编辑操作:等比缩放、自动布局、网格、吸附、对齐至网格等
  4. 更换渲染引擎
  5. 使用 WebGL 绘制 3D 图形
  6. 其他后续内容待定

实现效果

在线体验

在正式开始之前,先来看看我们最终希望实现的效果:

  • 一个可拖拽、缩放的无限画布
  • 支持网格和标尺显示
  • 视图可以平移、缩放,且缩放以鼠标为中心

无线画布.gif

一、Canvas 基本使用

canvasHTML5 提供的一个非常强大的绘图工具,适用于 2D3D 图形渲染。在 2D 编辑器中,canvas 是核心的图形渲染载体,他的绘制方法都在绘图上下文中,3D绘图上下文一般很少使用,一是复杂,而是性能问题

1.1 创建 Canvas 元素

html中添加一个 canvas 元素,并设置宽高:

<canvas id="myCanvas" width="800" height="600"></canvas>

1.2 获取 2D 上下文

canvas 元素本身并不直接用于绘图,真正负责绘图的是其上下文(context)。要在 canvas 上进行绘制,需要获取 2D 上下文:

const canvas = document.getElementById('myCanvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d');

通过 getContext('2d') 获取 2D 上下文,这个对象包含了所有 2D 绘图相关的方法和属性。上面有很多的绘制方法,具体后续我们会去尝试,你也可以自己拉取项目,自己尝试

1.3 Canvas 坐标系统

canvas 的坐标系统是以左上角为原点 (0, 0),向右为 x 轴正方向,向下为 y 轴正方向。所有绘图操作都基于这个坐标系统。

这张图展示了 Canvas 坐标系的基本原理。主要特点包括:

  • 左上角为原点 (0, 0)
  • x 轴向右,y 轴向下。
  • 图中我们绘制了一个坐标点 (200, 150) 和一个蓝色矩形(大小为 100x50

1.4 基本绘制操作

canvas 提供了丰富的绘图 API,支持绘制矩形、线条、圆形、文本等基本图形。这里我们简单介绍一些API,并不会详细去讲解,因为相关的文章实在是在太多了,我们主要是实践

1.4.1 绘制矩形

矩形是最简单的图形,canvas 提供了三种方法来绘制矩形:

  • fillRect(x, y, width, height):绘制填充的矩形。
  • strokeRect(x, y, width, height):绘制边框矩形。
  • clearRect(x, y, width, height):清除指定区域内的内容。

示例代码:

// 设置填充颜色
ctx.fillStyle = 'blue';
ctx.fillRect(10, 10, 100, 50); // 绘制一个蓝色矩形

// 设置边框颜色和线条宽度
ctx.strokeStyle = 'red';
ctx.lineWidth = 5;
ctx.strokeRect(130, 10, 100, 50); // 绘制一个红色边框的矩形

// 清除一个区域
ctx.clearRect(140, 20, 30, 30); // 在矩形上清除一部分

1.4.2 绘制路径和线条

除了矩形,canvas 还可以绘制任意形状的路径和线条:

// 开始一个新路径
ctx.beginPath();

// 移动画笔到起始点
ctx.moveTo(50, 150);

// 画一条直线
ctx.lineTo(200, 150);
ctx.lineTo(200, 250);

// 闭合路径并填充颜色
ctx.closePath();
ctx.fillStyle = 'green';
ctx.fill();

// 设置线条样式并描边
ctx.lineWidth = 2;
ctx.strokeStyle = 'black';
ctx.stroke();

1.4.3 绘制圆形和弧线

canvas 提供了 arc 方法来绘制圆形或弧线:

ctx.beginPath();
ctx.arc(300, 150, 50, 0, Math.PI * 2); // 绘制一个半径为 50 的圆
ctx.fillStyle = 'purple';
ctx.fill();
ctx.stroke();

arc 方法的参数分别是 (x, y, radius, startAngle, endAngle, anticlockwise),其中角度是以弧度为单位。

1.4.4 绘制文本

canvas 还可以绘制文本,支持设置字体、大小、对齐方式等:

ctx.font = '20px Arial';
ctx.fillStyle = 'black';
ctx.fillText('Hello Canvas!', 10, 100); // 在 (10, 100) 位置绘制文本

ctx.strokeText('Stroke Text', 10, 130); // 绘制描边文本

1.4.5 设置颜色和样式

canvas 支持多种颜色填充和样式:

  • fillStyle:设置填充颜色,可以是颜色值、渐变或图案。
  • strokeStyle:设置边框颜色。
  • lineWidth:设置线条宽度。
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // 半透明红色
ctx.fillRect(10, 200, 100, 100);

1.4.6 渐变和图案

canvas 支持线性渐变和径向渐变:

const gradient = ctx.createLinearGradient(0, 0, 200, 0);
gradient.addColorStop(0, 'red');
gradient.addColorStop(1, 'blue');
ctx.fillStyle = gradient;
ctx.fillRect(10, 350, 200, 50);

1.5 处理图形状态

Canvas 的绘图状态是全局的,当你修改 fillStylestrokeStyle 等属性时,这些属性会在后续的所有绘图操作中生效。从上面的截图中就可以看出,更改都是存储在上下文中,全局公用的

为了方便管理不同图形的样式,Canvas 提供了 saverestore 方法,用于保存和恢复绘图状态:

ctx.save(); // 保存当前状态

ctx.fillStyle = 'blue';
ctx.fillRect(10, 10, 100, 50);

ctx.restore(); // 恢复之前的状态
ctx.fillRect(130, 10, 100, 50); // 仍然使用最初的样式

这在复杂的绘制场景中非常有用。

二、图形学中的简单矩阵原理

常见的矩阵变换

三、React 如何将 Canvas 作为渲染引擎

主要就是在组件初始化过程过程中拿到canvas的绘图上下文和canvas实例

3.1 使用 React 管理 Canvas

首先,我们通过 useRef 获取 Canvas DOM 引用,然后在 useEffect 中初始化绘图上下文:

import React, { useEffect, useRef, useState } from 'react';
const CanvasContainer = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext('2d');
    if (ctx) {
      // 初始化绘图上下文
    }
  }, []);

  return (
    <div style={{ position: "relative" }}>
      <canvas
        ref={canvasRef}
        id="canvasContainer"
        height={window.innerHeight}
        width={window.innerWidth}
      ></canvas>
    </div>
  )
};

export default CanvasContainer;

拿到上下文就可以跟正常写组件调用方法一样使用canvas作为渲染引擎做一些事情了,同样的方式你也可以换其他渲染引擎。

3.2 响应式 Canvas 绘制

我们可以在 useEffect 中根据缩放比例和偏移量重新绘制画布内容,从而实现响应式的视图更新。

四、无限画布:如何实现拖拽、缩放画布

在编辑器中,拖拽和平移画布是常见的操作。这里我们主要关注以下两个交互:

  1. 缩放:通过鼠标滚轮调整画布缩放比例。
  2. 拖拽:按住鼠标左键并移动,调整画布的平移位置。

4.1 实现以鼠标为中心缩放功能

为了实现以鼠标为中心的缩放,我们需要考虑缩放前后鼠标位置的变化,核心就是保证缩放前后鼠标位置举例视口左上角距离不变,通过计算缩放前的偏移值和缩放后的的偏移值的差值,更新画布偏移值即可。假设当前鼠标在屏幕上的位置为 (mouseX, mouseY),在缩放前,这个位置在画布中的场景坐标为 (sceneX, sceneY)。为了让缩放后的画布依然将鼠标位置对应到这个场景坐标,我们需要调整画布的偏移量。

具体计算过程:

  1. 根据鼠标位置和当前偏移量跟缩放计算出鼠标对应的场景坐标。
  2. 调整缩放比例后,重新计算偏移量,使得鼠标对应的场景坐标不变。

代码实现如下:

// 处理缩放
  const handleWheel = (event: WheelEvent) => {
    event.preventDefault();

    const zoomFactor = 0.01; // 缩放速率调整为0.01
    const scaleChange = event.deltaY > 0 ? 1 - zoomFactor : 1 + zoomFactor;
    const newScale = Math.min(Math.max(0.1, scale * scaleChange), 5); // 确保缩放比例不会小于0.1

    const mouseX = event.clientX;
    const mouseY = event.clientY;

    const sceneX = (mouseX - offset.x) / scale;
    const sceneY = (mouseY - offset.y) / scale;

    setScale(newScale);
    setOffset({
      x: mouseX - sceneX * newScale,
      y: mouseY - sceneY * newScale,
    });

    setZoomIndicator(`${Math.round(newScale * 100)}%`);
  };

4.2 实现拖拽功能

拖拽画布通过监听鼠标按下、移动和抬起事件来实现。按下鼠标时开始记录位置,移动时根据鼠标的位移更新画布的偏移量。

核心逻辑如下:

const handleMouseDown = (event: MouseEvent) => {
  isDragging.current = true;
  lastMousePosition.current = { x: event.clientX, y: event.clientY };
};

const handleMouseMove = (event: MouseEvent) => {
  if (isDragging.current) {
    const deltaX = event.clientX - lastMousePosition.current.x;
    const deltaY = event.clientY - lastMousePosition.current.y; 

 // 更新偏移量
    setOffset((prev) => ({
      x: prev.x + deltaX,
      y: prev.y + deltaY,
    }));

    lastMousePosition.current = { x: event.clientX, y: event.clientY };
  }
};

const handleMouseUp = () => {
  isDragging.current = false;
};

// 绑定事件监听
useEffect(() => {
  window.addEventListener('mousemove', handleMouseMove);
  window.addEventListener('mouseup', handleMouseUp);

  return () => {
    window.removeEventListener('mousemove', handleMouseMove);
    window.removeEventListener('mouseup', handleMouseUp);
  };
}, []);

通过结合缩放和拖拽,我们可以实现一个可以自由操作的画布视图。

五、实现标尺

标尺可以帮助用户直观地了解画布上的坐标位置,通常在画布的顶部和左侧显示。

5.1 标尺绘制原理

标尺、网格线的核心是根据当前的缩放比例和偏移量,计算出适当的刻度,并在 Canvas 上绘制这些刻度值。

5.2 具体实现

在绘制标尺时,我们需要循环绘制每一个刻度线,并标记对应的数值。以下是一个基本的标尺绘制代码:

const drawRulers = (ctx, canvas) => {
  const rulerStep = 50; // 标尺间隔,实际应用中可动态调整
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.strokeStyle = "#000";
  ctx.font = "12px Arial";

  const startX = Math.floor(-offset.x / scale / rulerStep) * rulerStep;
  const startY = Math.floor(-offset.y / scale / rulerStep) * rulerStep;

  // 绘制顶部标尺
  for (let x = startX; x < canvas.width / scale; x += rulerStep) {
    const screenX = x * scale + offset.x;
    ctx.beginPath();
    ctx.moveTo(screenX, 0);
    ctx.lineTo(screenX, 20);
    ctx.stroke();
    ctx.fillText(x.toString(), screenX + 2, 15);
  }

  // 绘制左侧标尺
  for (let y = startY; y < canvas.height / scale; y += rulerStep) {
    const screenY = y * scale + offset.y;
    ctx.beginPath();
    ctx.moveTo(0, screenY);
    ctx.lineTo(20, screenY);
    ctx.stroke();
    ctx.fillText(y.toString(), 2, screenY + 15);
  }
};

通过这种方式,可以根据画布的缩放和偏移情况实时更新标尺显示。

六、完整代码

import { useEffect, useRef, useState } from "react";

let canvas2DContext: CanvasRenderingContext2D;

export const getCanvas2D = () => {
  return canvas2DContext;
};

const CanvasContainer = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [scale, setScale] = useState(1); // 缩放比例
  const [offset, setOffset] = useState({ x: 0, y: 0 }); // 画布平移偏移量
  const [zoomIndicator, setZoomIndicator] = useState("100%"); // 缩放标识
  const isDragging = useRef(false);
  const lastMousePosition = useRef({ x: 0, y: 0 });

  // 处理缩放
  const handleWheel = (event: WheelEvent) => {
    event.preventDefault();

    const zoomFactor = 0.01; // 缩放速率调整为0.01
    const scaleChange = event.deltaY > 0 ? 1 - zoomFactor : 1 + zoomFactor;
    const newScale = Math.min(Math.max(0.1, scale * scaleChange), 5); // 确保缩放比例不会小于0.1

    const mouseX = event.clientX;
    const mouseY = event.clientY;

    const sceneX = (mouseX - offset.x) / scale;
    const sceneY = (mouseY - offset.y) / scale;

    setScale(newScale);
    setOffset({
      x: mouseX - sceneX * newScale,
      y: mouseY - sceneY * newScale,
    });

    setZoomIndicator(`${Math.round(newScale * 100)}%`);
  };

  const handleMouseDownCanvas = (event: MouseEvent) => {
    isDragging.current = true;
    lastMousePosition.current = { x: event.clientX, y: event.clientY };
  };

  const handleMouseMoveCanvas = (event: MouseEvent) => {
    if (isDragging.current) {
      const deltaX = event.clientX - lastMousePosition.current.x;
      const deltaY = event.clientY - lastMousePosition.current.y;

      setOffset((prevOffset) => ({
        x: prevOffset.x + deltaX,
        y: prevOffset.y + deltaY,
      }));

      lastMousePosition.current = { x: event.clientX, y: event.clientY };
    }
  };

  const handleMouseUpCanvas = () => {
    isDragging.current = false;
  };

  useEffect(() => {
    const canvas = canvasRef.current;
    if (canvas && canvas.getContext) {
      const ctx = canvas.getContext("2d");
      console.log("✅ ~ ctx:", ctx);
      if (ctx) {
        canvas2DContext = ctx;
        canvas.addEventListener("mousedown", handleMouseDownCanvas);
        canvas.addEventListener("mousemove", handleMouseMoveCanvas);
        canvas.addEventListener("mouseup", handleMouseUpCanvas);
        canvas.addEventListener("wheel", handleWheel);

        return () => {
          canvas.removeEventListener("mousedown", handleMouseDownCanvas);
          canvas.removeEventListener("mousemove", handleMouseMoveCanvas);
          canvas.removeEventListener("mouseup", handleMouseUpCanvas);
          canvas.removeEventListener("wheel", handleWheel);
        };
      }
    }
  }, [scale, offset]);

  useEffect(() => {
    const ctx = getCanvas2D();
    const canvas = canvasRef.current as HTMLCanvasElement;

    const drawScene = () => {
      // 清空画布
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // 保存当前状态并应用缩放和平移
      ctx.save();
      ctx.translate(offset.x, offset.y);
      ctx.scale(scale, scale);

      // 绘制网格
      const step = 25; // 网格间隔
      ctx.strokeStyle = "#ddd";
      ctx.lineWidth = 1 / scale;

      // 获取当前视口范围
      const viewportWidth = canvas.width / scale;
      const viewportHeight = canvas.height / scale;

      const startX = Math.floor(-offset.x / scale / step) * step;
      const startY = Math.floor(-offset.y / scale / step) * step;
      const endX = startX + viewportWidth + step;
      const endY = startY + viewportHeight + step;

      // 绘制水平和垂直线
      for (let x = startX; x <= endX; x += step) {
        ctx.beginPath();
        ctx.moveTo(x, startY);
        ctx.lineTo(x, endY);
        ctx.stroke();
      }
      for (let y = startY; y <= endY; y += step) {
        ctx.beginPath();
        ctx.moveTo(startX, y);
        ctx.lineTo(endX, y);
        ctx.stroke();
      }

      // 绘制矩形在逻辑坐标 (200, 200) 位置
      const rectX = 200;
      const rectY = 200;
      const rectWidth = 100;
      const rectHeight = 50;
      ctx.strokeStyle = "#ff0000";
      ctx.lineWidth = 2 / scale;
      ctx.fillRect(rectX, rectY, rectWidth, rectHeight);

      ctx.restore();

      // 绘制标尺
      drawRulers();
    };

    const drawRulers = () => {
      // 计算标尺步长,确保步长为10的倍数
      let rulerStep = 10;
      while (rulerStep * scale < 50) {
        rulerStep *= 2;
      }

      ctx.strokeStyle = "#000";
      ctx.font = `12px Arial`; // 固定字体大小
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";

      // 获取当前视口范围
      const viewportWidth = canvas.width;
      const viewportHeight = canvas.height;

      // 顶部标尺
      const startX = Math.floor(-offset.x / scale / rulerStep) * rulerStep;
      const endX = startX + viewportWidth / scale + rulerStep;
      for (let x = startX; x <= endX; x += rulerStep) {
        const screenX = x * scale + offset.x;
        const sceneX = Math.round(x);
        ctx.beginPath();
        ctx.moveTo(screenX, 0);
        ctx.lineTo(screenX, 10); // 标尺高度为10像素
        ctx.stroke();
        ctx.fillText(`${sceneX}`, screenX, 20); // 显示刻度数值
      }

      // 左侧标尺
      const startY = Math.floor(-offset.y / scale / rulerStep) * rulerStep;
      const endY = startY + viewportHeight / scale + rulerStep;
      for (let y = startY; y <= endY; y += rulerStep) {
        const screenY = y * scale + offset.y;
        const sceneY = Math.round(y);
        ctx.beginPath();
        ctx.moveTo(0, screenY);
        ctx.lineTo(10, screenY); // 标尺高度为10像素
        ctx.stroke();
        ctx.fillText(`${sceneY}`, 20, screenY); // 显示刻度数值
      }
    };

    drawScene();
  }, [scale, offset]);

  return (
    <div style={{ position: "relative" }}>
      <canvas
        ref={canvasRef}
        id="canvasContainer"
        height={window.innerHeight}
        width={window.innerWidth}
        style={{ cursor: isDragging.current ? "grabbing" : "grab" }}
      ></canvas>
      <div
        style={{
          position: "absolute",
          top: 10,
          right: 10,
          backgroundColor: "rgba(0,0,0,0.5)",
          color: "#fff",
          padding: "5px 10px",
          borderRadius: "5px",
        }}
      >
        缩放: {zoomIndicator}
      </div>
    </div>
  );
};

export default CanvasContainer;

🍎 推荐阅读

工程化

本系列是一个从0到1的实现过程,如果您有耐心跟着实现,您可以实现一个完整的react18 + ts5 + webpack5 + 代码质量&代码风格检测&自动修复 + storybook8 + rollup + git action实现的一个完整的组件库模板项目。如果您不打算自己配置,也可以直接clone组件库仓库切换到rollup_comp分支即是完整的项目,当前实现已经足够个人使用,后续我们会新增webpack5优化、按需加载组件、实现一些常见的组件封装:包括但不限于拖拽排序、瀑布流、穿梭框、弹窗等

面试手写系列

react实现原理系列

其他

🍋 写在最后

如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!

感兴趣的同学可以关注下我的公众号ObjectX前端实验室

🌟 少走弯路 | ObjectX前端实验室 🛠️「精选资源|实战经验|技术洞见」