Canvas 绘图应用的双数组撤销重做实现


一、功能介绍

本文实现了一个基于 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();
    });
}

三、技术要点

  1. 双栈管理:使用两个栈分别管理已完成的操作和已撤销的操作

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

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

  4. 状态清空:新操作后清空重做栈,确保重做操作的正确性

  5. 边界检查:撤销/重做操作前检查栈是否为空,避免错误操作

  6. 画布重绘:每次操作后清空画布并重新绘制所有路径,确保状态正确

四、双数组 vs 指针实现

特性 双数组实现 指针实现
数据结构 两个数组(undoList, redoList) 一个数组 + 一个指针
内存使用 可能使用更多内存(存储重复操作) 更高效(只存储一次操作)
逻辑复杂度 较低(符合栈的直观操作) 稍高(需要管理指针位置)
实现难度 简单 中等
适用场景 操作数量较少的应用 操作数量较多的应用

五、应用场景

  • 在线绘图工具
  • 电子签名功能
  • 简单的图形编辑器
  • 教育类应用中的绘图功能
  • 任何需要撤销重做功能的交互应用

六、代码优化建议

  1. 性能优化

    • 对于复杂绘制,可以考虑使用离屏 Canvas 缓存绘制结果
    • 实现批量绘制,减少 Canvas API 调用次数
  2. 功能扩展

    • 添加颜色选择器和线条粗细调节
    • 实现橡皮擦功能
    • 支持多种图形绘制(直线、矩形、圆形等)
    • 添加快捷键支持(Ctrl+Z 撤销,Ctrl+Y 重做)
  3. 用户体验

    • 添加操作历史记录可视化
    • 实现操作预览功能
    • 优化移动端触摸支持
    • 添加绘制完成后的视觉反馈
  4. 代码结构

    • 封装为类,提高代码可维护性
    • 添加错误处理机制
    • 实现模块化设计,便于扩展

七、扩展实现

以下是一个添加了颜色选择器和线条粗细调节的增强版实现:

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>

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