Canvas 绘图应用的指针式撤销重做实现


一、功能介绍

本文实现了一个基于 Canvas 的简易绘图应用,支持以下功能:

  • ✏️ 鼠标绘制自由路径
  • 🔄 撤销操作
  • 🔁 重做操作
  • 💾 保存绘制结果为图片

二、技术实现

1. 核心数据结构

使用指针数组组合的方式管理绘制历史:

  • pointer:指针变量,记录当前绘制状态的索引
  • contentArr:数组,存储所有绘制路径的完整历史

这种方式的优势在于:

  • 无需维护两个数组(撤销栈和重做栈)
  • 操作逻辑更简洁
  • 内存使用更高效

2. 核心代码分析

初始化变量

// 定义一个指针
var pointer = -1;
// 定义一个数组
var contentArr = [];

const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");
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 }],
    };
    pointer++;
});

// 鼠标移动事件
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([...contentArr, currentPath]);
});

// 鼠标松开事件
canvas.addEventListener("mouseup", e => {
    isDrawing = false;
    contentArr.push(currentPath);
});

撤销/重做逻辑

// 撤销按钮点击事件
undoBtn.addEventListener("click", e => {
    // 如果当前指针在第一个元素上,不执行撤销操作
    if (pointer < 0) return;
    pointer--;
    context.clearRect(0, 0, canvas.width, canvas.height);
    drawPaths(contentArr);
});

// 重做按钮点击事件
redoBtn.addEventListener("click", e => {
    // 如果当前指针在最后一个元素,不执行恢复操作
    if (pointer == contentArr.length - 1) return;
    pointer++;
    context.clearRect(0, 0, canvas.width, canvas.height);
    drawPaths(contentArr);
});

绘制函数

// 画所有的路径
function drawPaths(paths) {
    paths.forEach((path, index) => {
        if (index <= pointer) {
            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();
        }
    });
}

三、技术要点

  1. 指针管理:通过 pointer 变量控制当前显示的绘制状态,撤销时指针左移,重做时指针右移

  2. 路径存储:每条路径包含颜色、线宽和点坐标数组,完整记录绘制信息

  3. 实时预览:鼠标移动时实时更新画布,提供流畅的绘制体验

  4. 状态判断:撤销/重做操作前检查指针位置,避免越界

  5. 绘制优化:只绘制指针范围内的路径,提高绘制效率

四、应用场景

  • 在线绘图工具
  • 电子签名功能
  • 简单的图形编辑器
  • 教育类应用中的绘图功能

五、代码优化建议

  1. 性能优化:对于复杂绘制,可以考虑使用离屏 Canvas 或 WebGL 提高性能

  2. 功能扩展

    • 添加颜色选择器
    • 添加线条粗细调节
    • 添加橡皮擦功能
    • 支持多种图形绘制(直线、矩形、圆形等)
  3. 用户体验

    • 添加绘制完成后的视觉反馈
    • 实现快捷键支持(Ctrl+Z 撤销,Ctrl+Y 重做)
    • 优化移动端触摸支持

六、总结

本文介绍的指针式撤销重做实现方案,通过一个指针变量和一个数组,简洁高效地实现了 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;
        }
        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>
            <br />
            <button id="undoBtn">撤销</button>
            <button id="redoBtn">重做</button>
            <button id="saveBtn">保存</button>
            <br />
            <img id="resultImg" />
        </div>

        <script>
            // 定义一个指针
            var pointer = -1;
            // 定义一个数组
            var contentArr = [];

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

            // 设置默认样式
            context.strokeStyle = "#000000";
            context.lineWidth = 2;
            context.lineCap = "round";
            context.lineJoin = "round";

            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 }],
                };
                pointer++;
            });

            // 鼠标移动事件
            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([...contentArr, currentPath]);
            });

            // 鼠标松开事件
            canvas.addEventListener("mouseup", e => {
                isDrawing = false;
                contentArr.push(currentPath);
            });

            // 鼠标离开事件
            canvas.addEventListener("mouseleave", e => {
                isDrawing = false;
            });

            // 撤销按钮点击事件
            undoBtn.addEventListener("click", e => {
                // 如果当前指针在第一个元素上,不执行撤销操作
                if (pointer < 0) return;
                pointer--;
                context.clearRect(0, 0, canvas.width, canvas.height);
                drawPaths(contentArr);
            });

            // 重做按钮点击事件
            redoBtn.addEventListener("click", e => {
                // 如果当前指针在最后一个元素,不执行恢复操作
                if (pointer == contentArr.length - 1) return;
                pointer++;
                context.clearRect(0, 0, canvas.width, canvas.height);
                drawPaths(contentArr);
            });

            // 保存按钮点击事件
            saveBtn.addEventListener("click", e => {
                const dataUrl = canvas.toDataURL();
                resultImg.src = dataUrl;
            });

            // 画所有的路径
            function drawPaths(paths) {
                paths.forEach((path, index) => {
                    if (index <= pointer) {
                        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>

文章作者: 弈心
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 弈心 !
评论
  目录