利用 Canvas 实现 Valine 评论画板涂鸦

1323°C 07-01-2021 notbyai
最近更新于:2022-11-18 19:40:42

文章摘要moonshot-v1-8k

Standby API Responsing..

评论涂鸦

前几天在 Joe(https://ae.js.cn/)网站上留言的时候发现了一个叫“画图模式”的东西,点进去后自动切换文本框到画板了(类似QQ涂鸦,你画我猜那种画板),然后可以在画板上画画,还可以选择画笔粗细、颜色等等,画错了还能撤销各种功能,欸感觉挺有意思的,当时也猜到了应该是用 canvas 做的,不过自己也不太了解这块,但就是感觉挺有意思的,加上我又喜欢魔改 valine 评论,所以立下计划决定给评论系统加上这么一个好玩的功能。

评论涂鸦画板样式

Canvas

说起 html 画图,肯定避不开 html5 的 canvas 技术,canvas 能提供的不仅是画图功能,很多网页游戏也都是基于 canvas 制作的。我们要实现 canvas 画板,首先还得了解 canvas 本身的一些语法 api 之类的东西,然后再思考实现的思路,最后再结合评论系统将功能写出来附加上去测试(关于 canvas 的基础语法可以在 w3school 或者 runoob 教程网站自行查询)

实现思路

简单来说首先要实现的还是画图功能,先创建 canvas 面板,再给面板添加画图触发事件(鼠标按下并移动、松开等),然后添加画板工具事件(画笔颜色、粗细),最后绑定完成画板功能事件(撤销、重做、擦除、清除)

Valine

通过创建 canvas 画板加入到 valine 评论中,需要先定位到 valine.js 中的 </textarea> 后添加元素

    <div class="canvas_paint_board" style="display:none">
<div class="paint_tools">
    <input id="fill" type="color" title="画笔颜色">  粗细  
    <input id="bold" type="number" title="画笔粗细">  
    <button id="undraw" title="上一步">撤销</button>  
    <button id="redraw" title="下一步">重做</button>  
    <button id="eraser" title="橡皮擦">擦除</button>  
    <button id="clear" title="全部清除">清屏</button> 
</div>
<canvas id="canvas"></canvas>

然后先定位到 class="vctrl" 在 vctrl 内部添加画板控制按钮

    <span class="painting-btn" title="Canvas 画图面板">涂鸦画板</span>

主要功能

完成以上配置可以看到已经添加的元素及切换功能,然后是一大串的 canvas 结合 valine 评论配置(以下代码格式化带注释,有问题可以留言)

已更新移动端代码支持

定位到 e.nodata.show(),e}} 后添加

    var mycanvas = document.getElementById('canvas'),  //canvas 元素
    ctx = mycanvas.getContext('2d'),  //创建 canvas 2d 画板
    vedit = document.getElementsByClassName("vedit")[0],  //canvas 父元素
    veditor = document.getElementById('veditor'),  //文本框 元素
    eraser = document.getElementById('eraser'),  //撤销(橡皮擦)按钮
    clear = document.getElementById('clear'),  //清屏 按钮
    number = document.getElementById('bold'),  //粗细 输入框
    color = document.getElementById('fill'),  //取色框
    lineColor = "#eb6844",  //默认画笔颜色(6位hex值)
    width = 1102,  //canvas 默认画板宽度
    height = 322,  //canvas 默认画板高度
    lineBold = 5,  //默认画笔粗细(5px)
    trigger = false,  //默认橡皮擦状态(关闭)
    drawCount = 0,  //已画图 计数
    drawHistory = [],  //已画图 数组
    //画笔移动函数
    move = (down_x, down_y, move_x, move_y) => {
        //判断是否启用橡皮擦
        if (trigger == true) {
            //canvas 擦除开始
            ctx.lineTo(down_x, down_y);
            ctx.lineTo(move_x, move_y);
            ctx.clearRect(move_x, move_y, number.value, number.value)
        } else {
            //canvas 画图开始
            ctx.beginPath();
            ctx.lineTo(down_x, down_y);
            ctx.lineTo(move_x, move_y);
            ctx.stroke()
        }
        //此函数内记录最后坐标会导致画笔闭合路径无效
    },
    // 定义画图函数
    draw = () => {
        //鼠标按下事件
        mycanvas.onmousedown = () => {
            let down_x = event.offsetX,  //按下时 x 坐标
                down_y = event.offsetY;  //按下时 y 坐标
            //鼠标移动事件
            mycanvas.onmousemove = () => {
                let move_x = event.offsetX,  //(按下并)移动时 x 坐标
                    move_y = event.offsetY;  //(按下并)移动时 y 坐标
                document.body.style.userSelect="none";  //禁用选中(优化体验)
                //画笔移动函数
                move(down_x,down_y,move_x,move_y);
                //记录最后坐标
                down_x = move_x;
                down_y = move_y;
                //首次移除画板触发断点续连
                mycanvas.onmouseup=()=>{
                    drawdone();
                    move(down_x,down_y,move_x,move_y)
                }
            };
            mycanvas.onmouseup=()=>{
                drawdone();  //修复点击鼠标松开后移动鼠标未解除绑定 bug
            }
        }
        //触摸按下事件
        mycanvas.ontouchstart = (ots) => {
            ots.preventDefault();  //阻止默认事件
            let boundingTopStart = canvas.getBoundingClientRect().top,  //触摸时 当前画板相对可视页面顶部距离
                boundingLeftStart = canvas.getBoundingClientRect().left,,  //触摸时 当前画板相对可视页面侧面距离
                down_x = ots.offsetX-boundingLeftStart,  //按下时 x 坐标
                down_y = ots.offsetY-boundingTopStart;  //按下时 y 坐标
            //触摸移动事件
            mycanvas.ontouchmove = (otm) => {
                document.body.style.userSelect="none";  //禁用选中(优化体验)
                let boundingTopMove = canvas.getBoundingClientRect().top,  //触摸并移动时 当前画板相对可视页面顶部距离
                    boundingLeftMove = canvas.getBoundingClientRect().left,,  //触摸并移动时 当前画板相对可视页面侧面距离
                    move_x = otm.offsetX-boundingLeftMove,  //(触摸并)移动时 x 坐标
                    move_y = otm.offsetY-boundingTopMove;  //(触摸并)移动时 y 坐标
                //画笔移动函数
                move(down_x,down_y,move_x,move_y);
                //记录最后坐标
                down_x = move_x;
                down_y = move_y;
                //首次移除画板触发断点续连
                mycanvas.ontouchend=()=>{
                    drawdone();
                    move(down_x,down_y,move_x,move_y)
                }
            }
        }
    },
    //清空已绑定事件函数
    unbind = () => {
        mycanvas.onmousedown = null;
        mycanvas.onmousemove = null;
        mycanvas.onmouseup = null;  //修复画笔移出画板外再移进画板内画笔断连现象
        document.body.style.userSelect="";  //解除禁用选中
        mycanvas.ontouchstart = null;
        mycanvas.ontouchmove = null;
        mycanvas.ontouchend = null;
    },
    //canvas 画图完成(松开)执行函数
    drawdone = () => {
        unbind();  //清空已绑定事件
        draw();  //执行画图函数
        let baseUrl = canvas.toDataURL("image/png"),  //获取已画图的 base64 链接
            imgDom = '<img id="draw" src="' + baseUrl + '" />';  //写入链接到 img 标签
        veditor.value = imgDom;  //将已写入 base64 的 img 标签写入输入框
        veditor.focus();  //聚焦输入框
        //drawCount++;  //直接画图次数+1(drawCount++)会导致无法撤销再涂鸦之后无法定位到最新画图记录(index)
        drawCount = drawHistory.length+1;  //定位到最新涂鸦记录
        drawHistory.push(baseUrl)  //记录当前涂鸦到已画图数组
    },
    //清屏函数
    clearCanvas = (ctx) => {
        ctx.clearRect(0, 0, width, height)
    },
    //canvas 默认配置
    initCanvas = () =>{
        canvas.width = width;  //canvas 宽度
        canvas.height = height;  //canvas 高度
        canvas.style = "cursor:crosshair";  //canvas 样式
        ctx.lineCap = 'round';  //画笔类型(圆)
        ctx.lineWidth = lineBold;  //画笔粗细
        ctx.strokeStyle = lineColor;  //画笔颜色
        color.value = lineColor;  //取色框颜色
        number.value = lineBold;  //输入框粗细值
    };
    //判断 canvas 父元素是否存在,是则获取父元素高宽写入 canvas,否则获取默认 canvas 高宽
    if(vedit != null){
        var cpb = document.getElementsByClassName('canvas_paint_board')[0],  //画板父元素
            btn = document.getElementsByClassName('painting-btn')[0],  //画板切换按钮
            btnSwitch = false;
        width = vedit.clientWidth - 40;  //定位到文本框宽度
        //切换点击事件
        btn.onclick = () =>{
            width = vedit.clientWidth - 40;  //修改默认 width 参数后再重复初始化参数(直接 canvas.width 覆盖会造成其他默认配置无效)
            initCanvas();  //更新修改后的 canvas 配置
            if(btnSwitch == false){
                btnSwitch = true;
                btn.innerText = "关闭面板";
                cpb.style.display = "block";
                veditor.style.cssText = "min-height:50px;max-height:0px;"
            }else{
                btnSwitch = false;
                btn.innerText = "涂鸦面板"; 
                cpb.style.display = "none";
                veditor.style.cssText = "min-height:;max-height:";
            }
            //min-height 和 max-height 属性的设置是为了在填充 base64 链接到 valine 文本框时防止字符过长导致的文本框高度问题
        }
    }
    initCanvas();  //初始化 canvas 参数
    draw();  //执行画图函数
    //颜色 输入框变更时,将变更后的值写入画笔颜色
    color.onchange = function() {
        ctx.strokeStyle = this.value
    };
    //粗细 输入框变更时,将变更后的值写入到画笔粗细
    number.onchange = function() {
        this.value <= 1 ? (this.value = 1, ctx.lineWidth = 1) : ctx.lineWidth = this.value;  //判断如果值小于1则强制等于1
    };
    //擦除 按钮点击时,切换显示状态
    eraser.onclick = () = >{
        //判断橡皮擦默认状态(trigger)如果已开启则关闭,否则开启
        trigger == false ? (trigger = true, eraser.innerText = "取消擦除") : (trigger = false, eraser.innerText = "擦除");
    };
    //清屏 按钮点击时,执行清屏函数
    clear.onclick = () = >{
        clearCanvas(ctx);
        veditor.value = null;  //清空输入框
        veditor.focus()  //聚焦输入框(过长的 base64 字符会导致清除后还能提交涂鸦到评论)
    };
    //撤销(上一步)事件点击函数
    undraw.onclick = () = >{
        drawCount > 0 ? drawCount--:drawCount = 0;  //判断画图次数并递减
        //判断画图次数,如果已是最后记录则清空并聚焦文本框,重置画图次数
        drawCount <= 1 ? (veditor.value = null, veditor.focus()) : false;  
        let stepback = drawHistory[drawCount - 1],  //选择当前涂鸦的前一个涂鸦数组
            imgDom = new Image,  //新建 image
            img = '<img id="draw" src="' + stepback + '" />';  //写入前一个涂鸦到 img 标签
        //判断并插入已写入 src 属性的 image 到文本框并聚焦
        stepback != undefined ? (imgDom.src = stepback, veditor.value = img, veditor.focus()) : false;  
        clearCanvas(ctx);  //撤销前执行清屏
        //给 image 绑定 load 事件后执行 canvas 自带的 drawImage() 画图函数
        imgDom.addEventListener('load', () = >{
            ctx.drawImage(imgDom, 0, 0)
        })
    };
    //重做(下一步)事件点击函数(和撤销类似,不再注释)
    redraw.onclick = () = >{
        drawCount < drawHistory.length ? drawCount++:drawCount = drawHistory.length;
        let stepfoward = drawHistory[drawCount - 1],
        imgDom = new Image,
        img = '<img id="draw" src="' + stepfoward + '" />';
        stepfoward != undefined ? (imgDom.src = stepfoward, veditor.value = img, veditor.focus()) : false;
        clearCanvas(ctx);
        imgDom.addEventListener('load', () = >{
            ctx.drawImage(imgDom, 0, 0)
        })
    };

21.7.18 修复信息

修复点击再松开鼠标时移动鼠标仍可继续绘画 bug

参考链接

Joe 的留言板

HTML5 实现橡皮擦的擦除效果

HTML5 中 canvas 绘图的撤销与反撤销功能实现


以上,有问题评论区留言。


评论留言

既来之则留之~ 欢迎在下方留言评论,提交评论后还可以撤销或重新编辑。(Valine 会自动保存您的评论信息到浏览器)