🧑💻 写在开头
点赞 + 收藏 === 学会 🤣🤣🤣
本篇是图形学专栏的开篇第一篇,目标是通过实现一个简单的 2D 编辑器博客,来巩固和分享一些图形学的知识。将平时写的demo项目展示在无限画布中。感兴趣的话大家可以收藏关注一下,这是件长期且有趣的事情。
🥑 你能学到什么?
希望你在阅读本文后不会觉得浪费了时间。如果你跟着学习,你将会掌握:
- Canvas 基本使用
- 图形学中的简单矩阵原理
- 如何在 React 中将 Canvas 作为渲染引擎
- 如何实现拖拽、缩放画布
- 如何实现标尺
- 如何实现无限画布和坐标系转换
专栏预告
- 实现无限画布、鼠标为中心缩放、标尺、移动画布 ✅
- 节点树的实现、创建维护、设计绘制节点
- 实现编辑操作:等比缩放、自动布局、网格、吸附、对齐至网格等
- 更换渲染引擎
- 使用 WebGL 绘制 3D 图形
- 其他后续内容待定
实现效果
在正式开始之前,先来看看我们最终希望实现的效果:
- 一个可拖拽、缩放的无限画布
- 支持网格和标尺显示
- 视图可以平移、缩放,且缩放以鼠标为中心
一、Canvas 基本使用
canvas
是 HTML5
提供的一个非常强大的绘图工具,适用于 2D
和 3D
图形渲染。在 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 的绘图状态是全局的,当你修改 fillStyle
、strokeStyle
等属性时,这些属性会在后续的所有绘图操作中生效。从上面的截图中就可以看出,更改都是存储在上下文中,全局公用的
为了方便管理不同图形的样式,Canvas 提供了 save
和 restore
方法,用于保存和恢复绘图状态:
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
中根据缩放比例和偏移量重新绘制画布内容,从而实现响应式的视图更新。
四、无限画布:如何实现拖拽、缩放画布
在编辑器中,拖拽和平移画布是常见的操作。这里我们主要关注以下两个交互:
- 缩放:通过鼠标滚轮调整画布缩放比例。
- 拖拽:按住鼠标左键并移动,调整画布的平移位置。
4.1 实现以鼠标为中心缩放功能
为了实现以鼠标为中心的缩放,我们需要考虑缩放前后鼠标位置的变化,核心就是保证缩放前后鼠标位置举例视口左上角距离不变,通过计算缩放前的偏移值和缩放后的的偏移值的差值,更新画布偏移值即可。假设当前鼠标在屏幕上的位置为 (mouseX, mouseY)
,在缩放前,这个位置在画布中的场景坐标为 (sceneX, sceneY)
。为了让缩放后的画布依然将鼠标位置对应到这个场景坐标,我们需要调整画布的偏移量。
具体计算过程:
- 根据鼠标位置和当前偏移量跟缩放计算出鼠标对应的场景坐标。
- 调整缩放比例后,重新计算偏移量,使得鼠标对应的场景坐标不变。
代码实现如下:
// 处理缩放
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优化、按需加载组件、实现一些常见的组件封装:包括但不限于拖拽排序、瀑布流、穿梭框、弹窗等
- 【前端工程化】项目搭建篇-项目初始化&prettier、eslint、stylelint、lint-staged、husky
- 【前端工程化】项目搭建篇-配置changelog、webpack5打包
- 【前端工程化】项目搭建篇-引入react、ts、babel解析es6+、配置css module
- 【前端工程化】组件库搭建篇-引入storybook、rollup打包组件、本地测试组件库
- 【前端工程化】包管理器篇-三大包管理器、npm工程管理、npm发布流程
- 【前端工程化】自动化篇-Github Action基本使用、自动部署组件库文档、github3D指标统计
- 【前端工程化】自动化篇-手写脚本一键自动tag、发包、引导登录npm
- 【前端工程化】monorepo篇-rush管理monorepo实践
面试手写系列
react实现原理系列
- 【react原理实践】使用babel手搓探索下jsx的原理
- 【喂饭式调试react源码】上手调试源码探究jsx原理
- 【上手调试源码系列】图解react几个核心包之间的关联
- 【上手调试源码系列】react启动流程,其实就是创建三大全局对象
其他
🍋 写在最后
如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!
感兴趣的同学可以关注下我的公众号ObjectX前端实验室
🌟 少走弯路 | ObjectX前端实验室 🛠️「精选资源|实战经验|技术洞见」