一、功能介绍
本文实现了一个基于 Canvas 的简易绘图应用,使用双数组栈的方式实现撤销重做功能,支持以下特性:
- ✏️ 鼠标绘制自由路径
- 🔄 撤销操作(Ctrl+Z)
- 🔁 重做操作(Ctrl+Y)
- 💾 保存绘制结果为图片
二、技术实现
1. 核心数据结构
使用双数组栈的方式管理绘制历史:
undoList:撤销栈,存储所有已完成的绘制操作redoList:重做栈,存储所有被撤销的操作
这种方式的优势在于:
- 逻辑清晰,符合栈的后进先出特性
- 操作直观,撤销时将操作从 undo 栈弹出并压入 redo 栈
- 易于理解和实现
2. 核心代码分析
初始化变量
const canvas = document.getElementById("canvas");
const undoBtn = document.getElementById("undoBtn");
const redoBtn = document.getElementById("redoBtn");
const saveBtn = document.getElementById("saveBtn");
const context = canvas.getContext("2d");
const resultImg = document.getElementById("resultImg");
let undoList = []; // 用于保存所有操作,用于撤销和重做
let redoList = []; // 用于保存所有撤销的操作,用于重做
let isDrawing = false;
let currentPath = null;
绘制逻辑
// 鼠标按下事件
canvas.addEventListener("mousedown", e => {
isDrawing = true;
currentPath = {
color: context.strokeStyle,
width: context.lineWidth,
points: [{ x: e.offsetX, y: e.offsetY }],
};
});
// 鼠标移动事件
canvas.addEventListener("mousemove", e => {
if (!isDrawing) return;
currentPath.points.push({ x: e.offsetX, y: e.offsetY });
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths([...undoList, currentPath]);
});
// 鼠标松开事件
canvas.addEventListener("mouseup", e => {
isDrawing = false;
undoList.push(currentPath);
redoList = []; // 新操作后清空重做栈
});
撤销/重做逻辑
// 撤销按钮点击事件
undoBtn.addEventListener("click", e => {
if (undoList.length === 0) return;
const lastPath = undoList.pop();
redoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
});
// 重做按钮点击事件
redoBtn.addEventListener("click", e => {
if (redoList.length === 0) return;
const lastPath = redoList.pop();
undoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
});
绘制函数
// 画所有的路径
function drawPaths(paths) {
paths.forEach(path => {
context.beginPath();
context.strokeStyle = path.color;
context.lineWidth = path.width;
context.moveTo(path.points[0].x, path.points[0].y);
path.points.slice(1).forEach(point => {
context.lineTo(point.x, point.y);
});
context.stroke();
});
}
三、技术要点
双栈管理:使用两个栈分别管理已完成的操作和已撤销的操作
路径数据结构:每条路径包含颜色、线宽和点坐标数组,完整记录绘制信息
实时预览:鼠标移动时实时更新画布,提供流畅的绘制体验
状态清空:新操作后清空重做栈,确保重做操作的正确性
边界检查:撤销/重做操作前检查栈是否为空,避免错误操作
画布重绘:每次操作后清空画布并重新绘制所有路径,确保状态正确
四、双数组 vs 指针实现
| 特性 | 双数组实现 | 指针实现 |
|---|---|---|
| 数据结构 | 两个数组(undoList, redoList) | 一个数组 + 一个指针 |
| 内存使用 | 可能使用更多内存(存储重复操作) | 更高效(只存储一次操作) |
| 逻辑复杂度 | 较低(符合栈的直观操作) | 稍高(需要管理指针位置) |
| 实现难度 | 简单 | 中等 |
| 适用场景 | 操作数量较少的应用 | 操作数量较多的应用 |
五、应用场景
- 在线绘图工具
- 电子签名功能
- 简单的图形编辑器
- 教育类应用中的绘图功能
- 任何需要撤销重做功能的交互应用
六、代码优化建议
性能优化:
- 对于复杂绘制,可以考虑使用离屏 Canvas 缓存绘制结果
- 实现批量绘制,减少 Canvas API 调用次数
功能扩展:
- 添加颜色选择器和线条粗细调节
- 实现橡皮擦功能
- 支持多种图形绘制(直线、矩形、圆形等)
- 添加快捷键支持(Ctrl+Z 撤销,Ctrl+Y 重做)
用户体验:
- 添加操作历史记录可视化
- 实现操作预览功能
- 优化移动端触摸支持
- 添加绘制完成后的视觉反馈
代码结构:
- 封装为类,提高代码可维护性
- 添加错误处理机制
- 实现模块化设计,便于扩展
七、扩展实现
以下是一个添加了颜色选择器和线条粗细调节的增强版实现:
HTML 部分
<div>
<canvas id="canvas" width="400" height="400"></canvas>
<div>
<input type="color" id="colorPicker" value="#000000" />
<input type="range" id="lineWidth" min="1" max="10" value="2" />
<button id="undoBtn">撤销</button>
<button id="redoBtn">重做</button>
<button id="saveBtn">保存</button>
</div>
<img id="resultImg" />
</div>
JavaScript 部分
const canvas = document.getElementById("canvas");
const colorPicker = document.getElementById("colorPicker");
const lineWidth = document.getElementById("lineWidth");
const undoBtn = document.getElementById("undoBtn");
const redoBtn = document.getElementById("redoBtn");
const saveBtn = document.getElementById("saveBtn");
const context = canvas.getContext("2d");
const resultImg = document.getElementById("resultImg");
let undoList = [];
let redoList = [];
let isDrawing = false;
let currentPath = null;
// 设置初始值
context.strokeStyle = colorPicker.value;
context.lineWidth = lineWidth.value;
// 颜色选择器变化事件
colorPicker.addEventListener("change", e => {
context.strokeStyle = e.target.value;
});
// 线条粗细变化事件
lineWidth.addEventListener("input", e => {
context.lineWidth = e.target.value;
});
// 鼠标按下事件
canvas.addEventListener("mousedown", e => {
isDrawing = true;
currentPath = {
color: context.strokeStyle,
width: context.lineWidth,
points: [{ x: e.offsetX, y: e.offsetY }],
};
});
// 鼠标移动事件
canvas.addEventListener("mousemove", e => {
if (!isDrawing) return;
currentPath.points.push({ x: e.offsetX, y: e.offsetY });
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths([...undoList, currentPath]);
});
// 鼠标松开事件
canvas.addEventListener("mouseup", e => {
isDrawing = false;
undoList.push(currentPath);
redoList = [];
});
// 鼠标离开事件
canvas.addEventListener("mouseleave", e => {
isDrawing = false;
});
// 撤销按钮点击事件
undoBtn.addEventListener("click", e => {
if (undoList.length === 0) return;
const lastPath = undoList.pop();
redoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
});
// 重做按钮点击事件
redoBtn.addEventListener("click", e => {
if (redoList.length === 0) return;
const lastPath = redoList.pop();
undoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
});
// 保存按钮点击事件
saveBtn.addEventListener("click", e => {
const dataUrl = canvas.toDataURL();
resultImg.src = dataUrl;
});
// 快捷键支持
document.addEventListener("keydown", e => {
// Ctrl+Z 撤销
if (e.ctrlKey && e.key === "z") {
e.preventDefault();
if (undoList.length > 0) {
const lastPath = undoList.pop();
redoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
}
}
// Ctrl+Y 重做
if (e.ctrlKey && e.key === "y") {
e.preventDefault();
if (redoList.length > 0) {
const lastPath = redoList.pop();
undoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
}
}
});
// 画所有的路径
function drawPaths(paths) {
paths.forEach(path => {
context.beginPath();
context.strokeStyle = path.color;
context.lineWidth = path.width;
context.moveTo(path.points[0].x, path.points[0].y);
path.points.slice(1).forEach(point => {
context.lineTo(point.x, point.y);
});
context.stroke();
});
}
八、总结
本文介绍的双数组撤销重做实现方案,通过两个栈(撤销栈和重做栈)的配合,实现了 Canvas 绘图的撤销重做功能。这种实现方式逻辑清晰,符合直觉,适合各种需要历史记录和状态管理的应用场景。
与指针式实现相比,双数组实现虽然可能在内存使用上稍高,但逻辑更简单,易于理解和实现。选择哪种实现方式,应根据具体应用的需求和规模来决定。
通过理解这种双栈管理的思路,你可以将其应用到更多需要撤销重做功能的场景中,如文本编辑器、表单填写、图形设计工具等。
相关链接
完整代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>双数组写法</title>
</head>
<style>
#canvas {
border: 1px solid #ccc;
cursor: crosshair;
margin-bottom: 10px;
}
.controls {
margin-bottom: 10px;
}
input[type="color"] {
margin: 5px;
cursor: pointer;
}
input[type="range"] {
margin: 5px;
width: 100px;
}
button {
margin: 5px;
padding: 5px 10px;
cursor: pointer;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
}
button:hover {
background-color: #f0f0f0;
}
#resultImg {
margin-top: 10px;
max-width: 400px;
border: 1px solid #ccc;
}
</style>
<body>
<div>
<canvas id="canvas" width="400" height="400"></canvas>
<div class="controls">
<input type="color" id="colorPicker" value="#000000" />
<input type="range" id="lineWidth" min="1" max="10" value="2" />
<button id="undoBtn">撤销</button>
<button id="redoBtn">重做</button>
<button id="saveBtn">保存</button>
</div>
<img id="resultImg" />
</div>
<script>
const canvas = document.getElementById("canvas");
const colorPicker = document.getElementById("colorPicker");
const lineWidth = document.getElementById("lineWidth");
const undoBtn = document.getElementById("undoBtn");
const redoBtn = document.getElementById("redoBtn");
const saveBtn = document.getElementById("saveBtn");
const context = canvas.getContext("2d");
const resultImg = document.getElementById("resultImg");
let undoList = []; // 用于保存所有操作,用于撤销和重做
let redoList = []; // 用于保存所有撤销的操作,用于重做
let isDrawing = false;
let currentPath = null;
// 设置初始值
context.strokeStyle = colorPicker.value;
context.lineWidth = lineWidth.value;
context.lineCap = "round";
context.lineJoin = "round";
// 颜色选择器变化事件
colorPicker.addEventListener("change", e => {
context.strokeStyle = e.target.value;
});
// 线条粗细变化事件
lineWidth.addEventListener("input", e => {
context.lineWidth = e.target.value;
});
// 鼠标按下事件
canvas.addEventListener("mousedown", e => {
isDrawing = true;
currentPath = {
color: context.strokeStyle,
width: context.lineWidth,
points: [{ x: e.offsetX, y: e.offsetY }],
};
});
// 鼠标移动事件
canvas.addEventListener("mousemove", e => {
if (!isDrawing) return;
currentPath.points.push({ x: e.offsetX, y: e.offsetY });
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths([...undoList, currentPath]);
});
// 鼠标松开事件
canvas.addEventListener("mouseup", e => {
isDrawing = false;
undoList.push(currentPath);
redoList = [];
});
// 鼠标离开事件
canvas.addEventListener("mouseleave", e => {
isDrawing = false;
});
// 撤销按钮点击事件
undoBtn.addEventListener("click", e => {
if (undoList.length === 0) return;
const lastPath = undoList.pop();
redoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
});
// 重做按钮点击事件
redoBtn.addEventListener("click", e => {
if (redoList.length === 0) return;
const lastPath = redoList.pop();
undoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
});
// 保存按钮点击事件
saveBtn.addEventListener("click", e => {
const dataUrl = canvas.toDataURL();
resultImg.src = dataUrl;
});
// 快捷键支持
document.addEventListener("keydown", e => {
// Ctrl+Z 撤销
if (e.ctrlKey && e.key === "z") {
e.preventDefault();
if (undoList.length > 0) {
const lastPath = undoList.pop();
redoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
}
}
// Ctrl+Y 重做
if (e.ctrlKey && e.key === "y") {
e.preventDefault();
if (redoList.length > 0) {
const lastPath = redoList.pop();
undoList.push(lastPath);
context.clearRect(0, 0, canvas.width, canvas.height);
drawPaths(undoList);
}
}
});
// 画所有的路径
function drawPaths(paths) {
paths.forEach(path => {
context.beginPath();
context.strokeStyle = path.color;
context.lineWidth = path.width;
context.moveTo(path.points[0].x, path.points[0].y);
path.points.slice(1).forEach(point => {
context.lineTo(point.x, point.y);
});
context.stroke();
});
}
</script>
</body>
</html>