如何从零实现一个画图小工具

857 阅读4分钟

在网上搜canvas的用法的时候突然有一个想法,能不能用canvas制作个画图工具呢,说干就干。

1、画图的时候,用户在页面上进行了哪些操作?

按传统画图工具来说,画一条线进行的操作就是:按下鼠标左键 -> 在画布上扭来扭曲 -> 松开鼠标左键,那么对应前端事件就是 mousedown -> mousemove -> mouseup,我们先新建一个div 和 一个canvas,在div上面进行的画画操作绘画到canvas上面(注意div和canvas的大大小一致,div的背景透明,canvas z-index=-1 将其置于div底下充当屏幕)

<div class="canvas"></div><canvas id="myCanvas">
</canvas><script>
let canvas = document.querySelector('.canvas')let c = document.getElementById("myCanvas");let ctx = c.getContext("2d");
// 鼠标在div上移动
canvas.addEventListener('mousemove', mousemove)
// 鼠标在div按下
canvas.addEventListener('mousedown', mousedown)
// 鼠标在div抬起
canvas.addEventListener('mouseup', mouseup)
</script>

2、如何绘制一条曲线?

一条曲线可以看成无数个短直线拼凑而成,那么如何绘制一条直线呢?用 canvas api中的 moveTo(x,y) (设置起点)和 lineTo(x,y) (设置终点) 以及 stroke()(绘制已定义的路径)就可以画出一条直线了

// 绘制线图
function drawLine(first_coordinate, second_coordinate) {
    ctx.beginPath() // 重置路径
    ctx.strokeStyle = color
    // 设置起点
    ctx.moveTo(first_coordinate.x, first_coordinate.y);
    // 设置终点
    ctx.lineTo(second_coordinate.x, second_coordinate.y);
    ctx.stroke();
}

直线我们已经画出来了,那么曲线就是一条条短直线拼凑而来,那么构成短直线的坐标哪里来?上一节已经提出 用户在画布上的操作 触发的前端事件是 mousedown -> mousemove -> mouseup, mousedown事件触发我们可以获取到落在画布上第一个点的坐标值:事件对象的e.offsetX和e.offsetY,接下来就是一直触发 mousemove 同样可以获取到对应的坐标值,松开鼠标的时候触发 mouseup 我们可以获取到最后完成点的坐标, 那么我们可以根据这些坐标值来画出用户的运动轨迹

首先我们定义一个记录这些坐标的数组 draw_travel_list,以及一个标志位candraw (用于在mousemove 里面判断用户是否在进行绘画操作, 只有在鼠标按下后在画布上移动才会进行绘画操作),在 mousedown 里面记录下第一个坐标点

let candraw = false // 是否可以画画let draw_travel_list = [] // 一条线的轨迹function mouseup(e) {    let coordinate = {        x: e.offsetX,        y: e.offsetY    }    candraw = true    draw_travel_list.push(coordinate)}

接下来在 mousemove 事件中记录运动的坐标值,并将数组内最后一个坐标和当前笔尖的坐标所构成的短直线绘制出来

function mousemove(e) {
    if (!candraw) { // 如果鼠标没有按下,在画布上移动时不做任何处理
        return
    }
    let coordinate = {
        x: e.offsetX,
        y: e.offsetY
    }
     drawLine(draw_travel_list[draw_travel_list.length - 1], coordinate); 
     draw_travel_list.push(coordinate);
}

最后在 mouseup 上绘制出最后一条短直线,并修改 candraw 状态,这里我们使用另外一个数组去存储这一次绘图的坐标数组(用于后面撤回功能)

let draw_travel_list_all = [] // 所有的绘图轨迹
function mouseup(e) {
    candraw = false
    let coordinate = {
        x: e.offsetX,
        y: e.offsetY
    }
    drawLine(draw_travel_list[draw_travel_list.length - 1], coordinate);
    draw_travel_list.push(coordinate)
    draw_travel_list_all.push( draw_travel_list ) // 记录当前绘制曲线的坐标
    draw_travel_list = []
}

演示效果

3、撤回之前所绘制过的内容

如何实现撤销操作呢,当时是有两种想法,第一种是每次绘图完生成一张当前画布的快照,撤销时就使用上一次的快照内容,第二种是之前 draw_travel_list_all 数组里面记录每次绘图的坐标数组,撤销时只要 pop 出最后一次的操作然后将数组中的坐标重新绘制一遍也可以实现。这里我们采用第二种方法

首先我们先实现撤销动作, 这里是使用 ctrl+z 按键进行撤销(苹果用户是 command + z 没有做特殊处理),那么我们需要监听键盘按下和抬起事件,这里定义了一个 keyCode 对象来存储已按下的键

let keyCode = {}
// 键盘按下
document.addEventListener('keydown', function (event) {
    event.preventDefault() // 禁止默认事件
    keyCode[event.keyCode] = true
    if (keyCode[17] && keyCode[90]) { //ctrl keycode为17  z keycode为90
        withdrawCanvas()  // 撤销操作
    }
})
// 键盘抬起
document.addEventListener('keyup', function (event) {
    keyCode[event.keyCode] = false
})

接下来编写 withdrawCanvas 撤销函数,我们先清空画布的内容,然后将 draw_travel_list_all 最后一条数据删除, 最后重新绘制

function withdrawCanvas(flag) { // flag 为false 时为测回, true时为重绘图画
    // 清除画布
    clearCanvas()
    // 删除最后一条数据
    draw_travel_list_all.pop()
    // 重新绘画之前的轨迹
    draw_travel_list_all.forEach(item => {  
        for (let i = 0; i < item.length - 2; i++) { 
            drawLine(item[i], item[i + 1])
        }
    })
}

演示效果

4、怎么画出不同颜色的线条?

canvas 提供了 strokeStyle 这个属性可以更改笔触的颜色,那么我每次绘画新线条的时候可以先选中想要的颜色,将 canvas strokeStyle 属性改成选中颜色的值就ok了

首先我们先做一个颜色选择器(这里代码就不贴了),直接看效果图

选中的颜色的时候将选中的颜色赋值给 stroke_color, 绘画的时候将 stroke_color 的值赋值给 canvas 的 strokeStyle 属性,重新修改 drawLine 函数

// 绘制线图
function drawLine(first_coordinate, second_coordinate, color) {
    ctx.beginPath() // 重置路径
    ctx.strokeStyle = color // 
    ctx.moveTo(first_coordinate.x, first_coordinate.y);
    ctx.lineTo(second_coordinate.x, second_coordinate.y);
    ctx.stroke();
}

// 调用drawLine多添加一个color参数
drawLine(draw_travel_list[draw_travel_list.length - 1], coordinate, stroke_color);

这里有一个编写过程中遇到的问题,当选中不同颜色后再绘制一条新曲线的话会将之前绘制的曲线颜色全部改变,这是因为canvas每次都是在重画,当你画第二笔的时候,其实第一笔也是在重新渲染,这就导致之前的曲线颜色也变掉,解决方法就是每次画新线段的路径前,都要用ctx.beginPath(),清除之前的路径

还有一点要注意的每次绘一条曲线画完不仅要记录坐标id,还要额外记录当前曲线的颜色,这样撤回就不会出现图形颜色改变的问题

function mouseup(e) {
    candraw = false
    let coordinate = {
        x: e.offsetX,
        y: e.offsetY
    }
    drawLine(draw_travel_list[draw_travel_list.length - 1], coordinate, stroke_color);
    draw_travel_list.push(coordinate)
    
    draw_travel_list_all.push({  // 记录当前直线的坐标位置和颜色
        draw_travel_list,
        color: stroke_color,
    })
    draw_travel_list = []
}

function withdrawCanvas() { 
    // 清除画布
    clearCanvas()
    // 删除数据
    draw_travel_list_all.pop() 
    // 重新绘制
    draw_travel_list_all.forEach(item => {
        for (let i = 0; i < item.draw_travel_list.length - 2; i++) {
            drawLine(item.draw_travel_list[i], item.draw_travel_list[i + 1], item.color)
        }
    })
}

演示效果

5、如何像画图那样快速生成多边形

这里写两个比较简单得矩形和椭圆,canvas 有提供两个api rectellipse 去绘制矩形和椭圆(如何使用这里就不做叙述了,有兴趣的朋友可以点超链接看看)

首先我们先做一个图形工具选择器(这里代码就不贴了),直接看效果图

其次我们设置一个标志位 stroke_style 去判断当前图形工具是曲线还是多边形,默认是 line(曲线),这时在 mousemove 中就需要去判断这个标志位如果是曲线则绘制短直线如果不是则不做任何操作,因为在绘制矩形和椭圆的时候只需要两点坐标就可以,mousedown 和 mouseup 给我们提供了这两点坐标

function mousemove(e) {
    if (!candraw) {
        return
    }
    let coordinate = {
        x: e.offsetX,
        y: e.offsetY
    }
    switch(stroke_style) {
        case 'line': // 曲线
            drawLine(draw_travel_list[draw_travel_list.length - 1], coordinate, stroke_color); 
            draw_travel_list.push(coordinate);
            break;
        case 'rectangle': // 矩形
            break;
        case 'ellipse':  // 椭圆
            break;
    }
}


function mouseup(e) {
    candraw = false
    let coordinate = {
        x: e.offsetX,
        y: e.offsetY
    }
    switch(stroke_style) {
        case 'line': 
            drawLine(draw_travel_list[draw_travel_list.length - 1], coordinate, stroke_color);
            draw_travel_list.push(coordinate)
            break; 
        case 'rectangle': 
            drawRectangle(draw_travel_list[0],coordinate,stroke_color);
            draw_travel_list[1] = coordinate
            break;
        case 'ellipse': 
            drawEllipse(draw_travel_list[0],coordinate,stroke_color);
            draw_travel_list[1] = coordinate;
            break;
    }
    draw_travel_list_all.push({  // 记录当前绘图的坐标位置和颜色,以及绘画类型
        draw_travel_list,
        color: stroke_color,
        style: stroke_style
    })
    draw_travel_list = []
}

// 绘制矩形
function drawRectangle(first_coordinate, second_coordinate, color) {  
    ctx.beginPath() // 重置路径
    ctx.strokeStyle = color
    let x = first_coordinate.x < second_coordinate.x ? first_coordinate.x : second_coordinate.x
    let y = first_coordinate.y < second_coordinate.y ? first_coordinate.y : second_coordinate.y
    let width = Math.abs(first_coordinate.x - second_coordinate.x)
    let height = Math.abs(first_coordinate.y - second_coordinate.y)
    ctx.rect(x,y,width,height)
    ctx.stroke();
}
// 绘制椭圆
function drawEllipse(first_coordinate, second_coordinate, color) {  
    ctx.beginPath() // 重置路径
    ctx.strokeStyle = color
    let x = (first_coordinate.x + second_coordinate.x) / 2 
    let y = (first_coordinate.y + second_coordinate.y) / 2
    let width = Math.abs(first_coordinate.x - second_coordinate.x) / 2
    let height = Math.abs(first_coordinate.y - second_coordinate.y) / 2
    ctx.ellipse(x,y,width,height,0,0,Math.PI*2)
    ctx.stroke();
}

然后在撤销操作的时候也需要判别绘图类型,重绘的时候调用不同的绘图函数

function withdrawCanvas() { 
    // 清除画布
    clearCanvas()
    // 重新绘画之前的轨迹
    draw_travel_list_all.pop()
    draw_travel_list_all.forEach(item => {
        switch(item.style) {
            case 'line': 
                for (let i = 0; i < item.draw_travel_list.length - 2; i++) {
                    drawLine(item.draw_travel_list[i], item.draw_travel_list[i + 1], item.color)
                }
                break;
            case 'rectangle': 
                drawRectangle(item.draw_travel_list[0],item.draw_travel_list[item.draw_travel_list.length-1], item.color );
                break;
            case 'ellipse': 
                drawEllipse(item.draw_travel_list[0],item.draw_travel_list[item.draw_travel_list.length-1], item.color);
        }
    })
}

演示效果

这里有一个缺陷,不能在拖动的过程中动态的显示图形,这里还没思路要如何实现,大家有兴趣可以在评论区下面给我一点建议

5、屏幕大小发生变化时,canvas 笔尖不能对焦

在编写过程中遇到一个问题,原本画布的笔尖是正好对焦的,当我开启开发者工具时,笔尖就不能对焦,这是由于canvas 的坐标是根据 canvas 上 width 和 height 的属性(并不是style里面的),这样当浏览器窗口缩小或扩大时,影响到canvas的 style里面的width与height,但不会改变canvas 本身的 width 和 height,这样就笔尖在画布上不对焦。

浏览器窗口缩放会触发 resize 事件,所以我们在事件里面重新给 canvas 定义 width 和 height

let canvas = document.querySelector('.canvas') // 操作的 Dom
let c = document.getElementById("myCanvas"); // dom 上面的画布c.setAttribute('width', canvas.offsetWidth)
c.setAttribute('height', canvas.offsetHeight)

// 屏幕大小改变
window.addEventListener('resize', function () {
    c.setAttribute('width', canvas.offsetWidth)
    c.setAttribute('height', canvas.offsetHeight)
})

这里又遇到了个问题,当 canvas 宽高重置时,上面的绘画也消失不见了!所以我们在 resize 时需要重新绘画一遍,我们可以使用 withdrawCanvas 撤回时调用的函数,只要让他不删除最后一个元素就可以了

function withdrawCanvas(flag) {
    ......
    if (!flag) {
        draw_travel_list_all.pop()
    }
    ......}

//屏幕大小改变
window.addEventListener('resize', function () {
    c.setAttribute('width', canvas.offsetWidth)
    c.setAttribute('height', canvas.offsetHeight)
    withdrawCanvas(true)
})

修改之前

修改之后 

还有 ctrl s 保存图片到本地的,这里就直接贴代码了(做gif好烦)

// 键盘按下
document.addEventListener('keydown', function (event) {
    event.preventDefault()
    keyCode[event.keyCode] = true
    if (keyCode[17] && keyCode[90]) { //ctrl keycode为17  z keycode为90
        withdrawCanvas()
    } else if(keyCode[17] && keyCode[83]) { // s keycode为83
        save()
    }
})

// 保存图片
function save() {
    let bloburl = c.toDataURL()
    var oA = document.createElement("a");
    oA.download = new Date().getTime();// 设置下载的文件名,默认是'下载'
    oA.href = bloburl;
    document.body.appendChild(oA);
    oA.click();
    oA.remove();
}

大功告成!下面是全部代码

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <meta http-equiv="X-UA-Compatible" content="IE=edge">    <meta name="viewport" content="width=device-width, initial-scale=1.0">    <title>Document</title>    <style>        body {            background-color: #eee;            margin: 0;            padding: 0;            height: 100vh;        }        .canvas {            position: relative;            width: 100%;            height: 100%;            background: transparent;        }        #myCanvas {            width: 100%;            height: 100%;            background: #fff;            z-index: -1;            position: absolute;            top: 0;            left: 0;        }        .main {            display: flex;            height: 100vh;        }        .left {            width: 56px;            padding: 20px;            background-color: #f5f6f7;        }        .right {            position: relative;            flex: 1;            cursor: crosshair;            /* background-color: #fff; */        }        .Pigment {            display: flex;            flex-wrap: wrap;        }        .tool {            display: flex;            flex-wrap: wrap;        }        .tool > div {            width: 56px;            height: 20px;            display: flex;            justify-content: center;            align-items: center;            background: #fdfeff;            margin-bottom: 4px;            border: 1px solid #fdfeff;            box-sizing: border-box;        }        .tool > div:hover {            background: #eff6fe;            border: 1px solid #aad3fe;        }        .line {            width: 80%;            height: 1px;            background-color: #2672af;        }        .ellipse  {            width: 80%;            height: 80%;            border: 1px solid #2672af;            border-radius: 50%;            background: #eff7fa;        }        .rectangle {            width: 80%;            height: 80%;            border: 1px solid #2672af;            background: #eff7fa;                    }    </style></head><body>    <div class="main">        <div class="left">            <div class="tool">                <div >                    <div class="line"></div>                </div>                <div>                    <div class="ellipse"></div>                </div>                <div>                    <div class="rectangle"></div>                </div>            </div>            <div class="Pigment">            </div>        </div>        <div class="right">            <div class="canvas">            </div>            <canvas id="myCanvas">            </canvas>        </div>    </div>    <script>        let candraw = false  // 是否可以画画        let keyCode = {} // 记录键盘按下键值        let draw_travel_list = [] // 一条画画轨迹        let draw_travel_list_all = [] // 所有的画画轨迹        let stroke_color = '#000' // 画笔颜色        let stroke_style = 'line' // 画笔样式        let canvas = document.querySelector('.canvas')        let c = document.getElementById("myCanvas");        c.setAttribute('width', canvas.offsetWidth)        c.setAttribute('height', canvas.offsetHeight)        let ctx = c.getContext("2d");        function withdrawCanvas(flag) { // flag 为false 时为测回, true时为重绘图画            // 清除画布            clearCanvas()            // 重新绘画之前的轨迹            if (!flag) {                draw_travel_list_all.pop()            }            draw_travel_list_all.forEach(item => {                switch(item.style) {                    case 'line':                         for (let i = 0; i < item.draw_travel_list.length - 2; i++) {                            drawLine(item.draw_travel_list[i], item.draw_travel_list[i + 1], item.color)                        }                        break;                    case 'rectangle':                         drawRectangle(item.draw_travel_list[0],item.draw_travel_list[item.draw_travel_list.length-1], item.color );                        break;                    case 'ellipse':                         drawEllipse(item.draw_travel_list[0],item.draw_travel_list[item.draw_travel_list.length-1], item.color);                }            })        }        // 清除画布        function clearCanvas() {            ctx.fillRect(0, 0, canvas.offsetWidth, canvas.offsetHeight);            ctx.beginPath();            ctx.closePath()            ctx.fill();            ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight)        }        // 鼠标在dom上移动        canvas.addEventListener('mousemove', mousemove)        // 鼠标按下事件        canvas.addEventListener('mousedown', mousedown)        // 鼠标按键抬起        canvas.addEventListener('mouseup', mouseup)        function mousemove(e) {            if (!candraw) {                return            }            let coordinate = {                x: e.offsetX,                y: e.offsetY            }            switch(stroke_style) {                case 'line': // 曲线                    drawLine(draw_travel_list[draw_travel_list.length - 1], coordinate, stroke_color);                     draw_travel_list.push(coordinate);                    break;                case 'rectangle': // 矩形                    break;                case 'ellipse':  // 椭圆                    break;            }        }                function mousedown(e) {            let coordinate = {                x: e.offsetX,                y: e.offsetY            }            candraw = true            draw_travel_list.push(coordinate)        }                function mouseup(e) {            candraw = false            let coordinate = {                x: e.offsetX,                y: e.offsetY            }            switch(stroke_style) {                case 'line':                     drawLine(draw_travel_list[draw_travel_list.length - 1], coordinate, stroke_color);                    draw_travel_list.push(coordinate)                    break;                 case 'rectangle':                     drawRectangle(draw_travel_list[0],coordinate,stroke_color);                    draw_travel_list[1] = coordinate                    break;                case 'ellipse':                     drawEllipse(draw_travel_list[0],coordinate,stroke_color);                    draw_travel_list[1] = coordinate;                    break;            }            draw_travel_list_all.push({  // 记录当前绘图的坐标位置和颜色,以及绘画类型                draw_travel_list,                color: stroke_color,                style: stroke_style            })            draw_travel_list = []        }        // 绘制线图        function drawLine(first_coordinate, second_coordinate, color) {            ctx.beginPath() // 重置路径            ctx.strokeStyle = color            ctx.moveTo(first_coordinate.x, first_coordinate.y);            ctx.lineTo(second_coordinate.x, second_coordinate.y);            ctx.stroke();        }        // 绘制矩形        function drawRectangle(first_coordinate, second_coordinate, color) {              ctx.beginPath() // 重置路径            ctx.strokeStyle = color            let x = first_coordinate.x < second_coordinate.x ? first_coordinate.x : second_coordinate.x            let y = first_coordinate.y < second_coordinate.y ? first_coordinate.y : second_coordinate.y            let width = Math.abs(first_coordinate.x - second_coordinate.x)            let height = Math.abs(first_coordinate.y - second_coordinate.y)            ctx.rect(x,y,width,height)            ctx.stroke();        }        // 绘制椭圆        function drawEllipse(first_coordinate, second_coordinate, color) {              ctx.beginPath() // 重置路径            ctx.strokeStyle = color            let x = (first_coordinate.x + second_coordinate.x) / 2             let y = (first_coordinate.y + second_coordinate.y) / 2            let width = Math.abs(first_coordinate.x - second_coordinate.x) / 2            let height = Math.abs(first_coordinate.y - second_coordinate.y) / 2            ctx.ellipse(x,y,width,height,0,0,Math.PI*2)            ctx.stroke();        }                // 键盘按下        document.addEventListener('keydown', function (event) {            event.preventDefault()            keyCode[event.keyCode] = true            if (keyCode[17] && keyCode[90]) { //ctrl keycode为17  z keycode为90                withdrawCanvas()            } else if(keyCode[17] && keyCode[83]) { // s keycode为83                save()            }        })        // 键盘抬起        document.addEventListener('keyup', function (event) {            keyCode[event.keyCode] = false        })        //屏幕大小改变        window.addEventListener('resize', function () {            c.setAttribute('width', canvas.offsetWidth)            c.setAttribute('height', canvas.offsetHeight)            withdrawCanvas(true)        })                // 添加左边画笔颜色选择栏        addTool()        function addTool() {            let pigmentDom = document.querySelector('.Pigment')            // 添加颜料工具            addPigmentItem()            function addPigmentItem() {                let pigmentList = [                    '#000000',                    '#7f7f7f',                    '#880015',                    '#ed1c24',                    '#ff7f27',                    '#fff200',                    '#22b14c',                    '#00a2e8',                    '#3f48cc',                    '#a349a4',                    '#ffffff',                    '#c3c3c3',                    '#b97a57',                    '#ffaec9',                    '#ffc90e',                    '#efe4b0',                    '#b5e61d',                    '#99d9ea',                    '#7092be',                    '#c8bfe7',                ]                // 常用颜色                pigmentList.forEach(item => {                    let elt = document.createElement('div')                    elt.style.background = item                    elt.style.width = '20px'                    elt.style.height = '20px'                    elt.style.border = '2px solid #a0a0a0'                    elt.style.margin = '2px'                    elt.addEventListener('mouseover', function () {                        elt.style.border = '2px solid #64a5e7'                    })                    elt.addEventListener('mouseout', function () {                        elt.style.border = '2px solid #a0a0a0'                    })                    elt.addEventListener('click', function () {                        stroke_color = elt.style.background                    })                    pigmentDom.appendChild(elt)                })                // 颜色选择器                let color_choose_container = document.createElement('input')                color_choose_container.setAttribute('type', 'color')                color_choose_container.style.width = '48px'                color_choose_container.addEventListener('change', function (e) {                    stroke_color = color_choose_container.value                })                pigmentDom.appendChild(color_choose_container)            }        }        // 工具栏事件        toolEvent()        function toolEvent() {              let line = document.querySelector('.line')            let rectangle = document.querySelector('.rectangle')            let ellipse = document.querySelector('.ellipse')            line.addEventListener('click',  ()=>stroke_style='line')            rectangle.addEventListener('click',  ()=>stroke_style='rectangle')            ellipse.addEventListener('click',  ()=>stroke_style='ellipse')        }            // 保存图片        function save() {            let bloburl = c.toDataURL()            var oA = document.createElement("a");            oA.download = new Date().getTime();// 设置下载的文件名,默认是'下载'            oA.href = bloburl;            document.body.appendChild(oA);            oA.click();            oA.remove();        }    </script></body></html>

大家的点赞就是我不摸鱼的动力!!!