使用canvas实现简单的白板功能

快速入门canvas

准备画布

html 复制代码
/* 注意:canvas画布的width和height属性与canvas的style属性的width和height不是同一个东西 */
<canvas class="canvas" width="640" height="480" style="outline:1px solid gray;"></canvas>
js 复制代码
/* 1.获取画板元素,画板没有图形绘制的能力 2.获取context对象所有的绘制操作都是通过context对象来完成*/
let canvas = document.querySelector('.canvas')
let ctx = canvas.getContext('2d')

绘制直线

js 复制代码
ctx.moveTo(200,10);//指定点(200,10)
ctx.lineTo(240,10);//创建一条从(200,10)到(240,10)的线段,此时画布上什么也没有
ctx.moveTo(200,15);//指定点(200,15)
ctx.lineTo(240,15);//创建一条从(200,15)到(240,15)的线段,此时画布上依然什么也没有
ctx.moveTo(200,20);//指定点(200,20)
ctx.lineTo(240,20);//创建一条从(200,50)到(240,20)的线段,此时画布上依然什么也没有
ctx.stroke();//绘制路径,此时画布上绘制了三条线段

moveTo和lineTo都是将路径移动到新的指定点,区别在于moveTo不会创建新的线段,而lineTo会生成从上一个指定点到当前指定点的线段。路径生成后并不会自动绘制,需要调用stroke方法绘制路径。

绘制圆弧

js 复制代码
ctx.beginPath();//新建一条路径
ctx.arc(220, 100, 50, 0, 2 * Math.PI);//创建圆弧,圆心为(220,100),半径为50,由0弧度到2*Math.PI的圆弧,即完整的圆
ctx.stroke();//绘制路径
ctx.beginPath();//新建一条路径
ctx.arc(220, 100, 50, Math.PI, 0);//创建圆弧,圆心为(220,100),半径为50,由0弧度到2*Math.PI的圆弧,即完整的圆
ctx.arc(245, 100, 25, 0, Math.PI);//创建圆弧,圆心为(245, 100),半径为25,由0弧度到Math.PI的圆弧
ctx.arc(195, 100, 25, 0, Math.PI,true);//创建圆弧,圆心为(195, 100),半径为25,由0弧度到Math.PI的圆弧,最后一个参数控制逆时针绘制
ctx.fill();//填充路径

arc绘制圆弧的注意事项:

  • 0弧度和2*Math.PI都是在圆心的右边,默认顺时针绘制。
  • 圆弧的起点和终点都会记录在绘制路径中,如果没有新建绘制路径,则路径终点会指定为圆弧终点。
  • 可以通过第六个参数设置绘制的方向,是一个布尔值,为true时逆时针绘制。同样绘制从0弧度到Math.PI的圆弧,顺时针绘制只能画出下半圆,而逆时针绘制只能画出上半圆。
  • 使用fill填充路径,会自动闭合路径,连接路径中的第一点与最后一点。

绘制矩形

矩形的绘制比较特殊,存在多种实现方式

  • 方法一:通过生成多条线段闭合成矩形,再绘制
js 复制代码
ctx.beginPath();//新建一条路径
ctx.moveTo(10,10);//指定点(10,10)
ctx.lineTo(50,10);//创建一条从(10,10)到(50,10)的线段
ctx.lineTo(50,100);//创建一条从(50,10)到(50,100)的线段
ctx.lineTo(10,100);//创建一条从(50,100)到(10,100)的线段
ctx.closePath();//闭合一条路径,会自动将最后一点与起点连接
ctx.stroke();
ctx.fill();//填充路径
  • 方法二:通过直接生成矩形,再进行绘制
js 复制代码
ctx.beginPath();//新建一条路径
ctx.rect(10,20,50,50);//创建矩形,矩形左上角坐标为(10,20),长宽皆为50
ctx.stroke();
ctx.fillStyle = "red";//填充样式
ctx.fill();//填充路径
  • 方法三:直接绘制矩形
js 复制代码
// 不用单独调用描边或填充方法,且不会影响当前路径的绘制
ctx.strokeRect(10, 200, 50, 50);
ctx.fillRect(10, 200, 50, 50);
  • 方法四:清除矩形区域中的内容,清除区域完全透明
js 复制代码
ctx.clearRect(0,0,100,400);//canvas中清除画布内容只能通过这一个方法

绘制曲线

绘制曲线用到了贝塞尔曲线,相当复杂,就不讲了。这里只给出绘制曲线的方法。

  • quadraticCurveTo(cp1x, cp1y, x, y)。绘制二次贝塞尔曲线,cp1x,cp1y 为一个控制点,x,y 为结束点。
  • bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)。绘制三次贝塞尔曲线,cp1x,cp1y为控制点一,cp2x,cp2y为控制点二,x,y为结束点。

站内有大佬介绍贝塞尔曲线的原理,感兴趣的可以去看看:深入理解贝塞尔曲线

绘制椭圆弧

js 复制代码
ctx.fillStyle = "black";
ctx.beginPath();
ctx.ellipse(195,100,10,10,0,0,2*Math.PI);//参数依次为圆心x轴坐标、y轴坐标、长轴半径、短轴半径、旋转弧度、起始弧度、结束弧度
ctx.fill();

绘制椭圆弧的方法与绘制圆弧的方法类似,只是半径由一个变为两个且多了一个旋转弧度。而如果你用上面的几行代码进行绘制,你会发现绘制出来依然是一个圆形。这是因为圆是特殊的椭圆,当椭圆的长轴半径与短轴半径相同时,这个椭圆就是一个圆。所以,通过绘制椭圆弧的方法,也能绘制出圆弧。绘制椭圆弧的方法同样默认顺时针绘制,如果希望逆时针绘制,可在最后添加一个布尔值true

绘制文本

js 复制代码
ctx.fillStyle = "blue";
ctx.strokeStyle = "green";
ctx.font='48px serif';//设置字体大小,必须同时设置字体
ctx.strokeText('天道酬勤',50,400)
ctx.fillText('人定胜天',350,400)

需要注意的是,canvas中的文本是通过路径绘制出来的图像,并不是真的文字,无法像普通文字一样选中它们。

裁切

js 复制代码
ctx.beginPath();
ctx.rect(0, 350, 600, 200);
ctx.clip();//沿路径裁切
ctx.beginPath();
ctx.arc(100,370,50,0,2*Math.PI);
ctx.fill();
ctx.clearRect(0,0,300,480)

裁切操作后,描边和填充以及清除等操作,都只会在裁切区域内生效,超出裁切区域的操作不会生效。裁切不会影响画布上原来的内容。

转换

js 复制代码
ctx.translate(100,100);//平移
ctx.rotate(Math.PI/4);//旋转
ctx.scale(2,1)//缩放,值为-1时代表翻转

需要注意的是,转换操作针对的是坐标轴,而非元素,且不会影响画板上原来的内容。

样式

细心的你应该发现了,前面的教学中,我对一些属性进行了赋值,比如fillStyle、strokeStyle和font属性,这些绘图上下文中的属性,全部都是对绘图样式进行设置。常用的属性还有lineWidthlineCaptextBaseline等。

lineWidth设置线宽,默认为1.0,不带单位。 lineCap设置线条端点样式,默认值为buzz,支持的值有squareroundtextBaseline设置文本基准线位置,默认值为alphabetic,支持的值还有tophangyingmiddleideographicbottom

保存和恢复

saverestore两个方法只看方法名可能会造成误解。有些人会以为save会保存整个画板的内容,restore会将保存的内容恢复,比如说我自己。其实canvas画板中的内容可以看成两个部分:绘制效果和绘图状态。

  • stroke()、fill()、strokeRect()、fillRect()和clearRect()等方法,将路径描边或填充到画板上,路径和画板上绘制的图像就是绘制效果,路径只能通过beginPath()进行重置。
  • 绘图状态包括裁切路径、转换和其他绘制样式。

save()方法会将绘图状态压入栈内,可以保存多个绘图状态。restore()方法将绘图状态从栈中弹出,如果没有保存的绘图状态,该方法不会有任何效果。save()和restore()方法都是对绘图状态进行操作,对绘图效果没有影响。也就是说,在保存后,新绘制的内容在恢复时不会消失,清除的内容在恢复时也不会复原。

你想保存和恢复绘制的图像?通过getImageDataputImageData两个方法即可实现。

  • getImageData(x,y,w,h)返回一个ImageData对象,包含画布从(x,y)开始,宽w,高h的矩形区域的像素点信息,是一个数组,每四个数据为一组,对应一个像素点的rgba值。
  • putImageData(imageData,x,y)传入ImageData对象,在画布的(x,y)开始,绘制ImageData对象保存的像素信息。

小练习

结合上述知识,你已经可以用canvas绘制出任意多边形、曲线和文字,现在试着自己绘制一个好看的图形,比如一个八卦图:

实现白板功能

功能分析

我们需要画笔、檫除、图形、文本工具、撤销等功能。本文只做简单实现,不做太复杂的功能。比如图形只实现矩形和椭圆形,不做五角星、三角形、五边形等多边形的实现。

总结一下,本文实现的在线白板功能有:

  1. 画笔工具
  2. 檫除工具
  3. 矩形工具
  4. 椭圆工具
  5. 文本工具
  6. 撤销

画笔工具

画笔工具核心功能:根据鼠标按下后移动的轨迹,绘制图形,直到鼠标松开。

  1. 按下鼠标。重置路径,将鼠标点击的位置添加到路径,监听鼠标移动事件。
  2. 鼠标移动。将鼠标移动到的位置添加到路径,并与路径的上一点相连(lineTo),绘制并重置路径。
  3. 松开鼠标。清除鼠标移动监听。
js 复制代码
// 新建空数组,用于保存一次按下鼠标后到鼠标松开记录的路径。
let points = [];
let snapshot = null;
// 鼠标移动回调
function drawLine(ev){
    ctx.putImageData(snapshot,0,0)
    points.push({ x: ev.offsetX, y: ev.offsetY });
    ctx.beginPath()
    ctx.moveTo(points[0].x,points[0].y)
    for(let i = 1;i<points.length;i++){
      ctx.lineTo(points[i].x,points[i].y)
    }
    ctx.stroke()
};
canvas.addEventListener("mousedown",(ev)=>{
    // 重置路径
    points = [];
    points.push({ x: ev.offsetX, y: ev.offsetY });
    // 保存当前画布内容
    snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
    canvas.addEventListener("mousemove",drawLine);
})
canvas.addEventListener("mouseup",(ev)=>{
    canvas.removeEventListener("mousemove",drawLine);
})

矩形工具

绘制矩形步骤:鼠标按下,移动鼠标,绘制矩形并随鼠标移动调整矩形的尺寸与位置,松开鼠标。

  1. 按下鼠标。重置路径,将鼠标点击的位置添加到路径,监听鼠标移动事件。
  2. 鼠标移动。将鼠标按下位置记为矩形的顶点,将鼠标的当前位置作为对角的顶点,绘制矩形路径。
  3. 松开鼠标。清除鼠标移动监听。
js 复制代码
// 新建一个数组,用于保存一次按下鼠标后到鼠标松开记录的路径。
let points = [];
let snapshot = null;
// 鼠标移动回调
function drawRect(ev){
    ctx.putImageData(snapshot,0,0)
    points[1]=({ x: ev.offsetX, y: ev.offsetY });
    let width = ev.offsetX - points[0].x
    let height = ev.offsetY - points[0].y
    // 矩形的宽高可以是负数
    ctx.strokeRect(points[0].x,points[0].y,width,height)
};
canvas.addEventListener("mousedown",(ev)=>{
    // 重置路径
    points = [];
    points.push({ x: ev.offsetX, y: ev.offsetY });
    // 保存当前画布内容
    snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
    canvas.addEventListener("mousemove",drawRect);
})
canvas.addEventListener("mouseup",(ev)=>{
    canvas.removeEventListener("mousemove",drawRect);
})

椭圆工具

绘制椭圆:鼠标按下,移动鼠标,绘制椭圆并随鼠标移动调整椭圆的尺寸与位置,松开鼠标。

  1. 按下鼠标。重置路径,将鼠标点击的位置添加到路径,监听鼠标移动事件。
  2. 鼠标移动。将鼠标按下位置与鼠标的当前位置作为参考,绘制椭圆路径。
  3. 松开鼠标。清除鼠标移动监听。

先来看看第二点,如何计算椭圆的路径?

如图,点a是鼠标按下的位置,点b是鼠标移动到的位置,则点c是我们要绘制的椭圆圆心的位置。

js 复制代码
// 新建一个数组,用于保存一次按下鼠标后到鼠标松开记录的路径。
let points = [];
let snapshot = null;
// 鼠标移动回调
function drawEllipse(ev){
    ctx.putImageData(snapshot,0,0)
    points[1] = ({ x: ev.offsetX, y: ev.offsetY });
    let x = (ev.offsetX + points[0].x) / 2
    let y = (ev.offsetY + points[0].y) / 2
    //椭圆的半径不可以是负数
    let xRadius = Math.abs(ev.offsetX - points[0].x) / 2
    let yRadius = Math.abs(ev.offsetY - points[0].y) / 2
    ctx.beginPath()
    ctx.ellipse(x, y, xRadius, yRadius, 0, 0, 2 * Math.PI)
    ctx.stroke()
};
canvas.addEventListener("mousedown",(ev)=>{
    // 重置路径
    points = [];
    points.push({ x: ev.offsetX, y: ev.offsetY });
    // 保存当前画布内容
    snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
    canvas.addEventListener("mousemove",drawEllipse);
})
canvas.addEventListener("mouseup",(ev)=>{
    canvas.removeEventListener("mousemove",drawEllipse);
})

檫除工具

檫除:鼠标按下,移动鼠标,将鼠标移动过的区域内容清空变为透明,松开鼠标。

  1. 按下鼠标。重置路径,将鼠标点击的位置添加到路径,监听鼠标移动事件。
  2. 鼠标移动。将鼠标按下位置与鼠标的当前位置作为参考,清空内容。
  3. 松开鼠标。清除鼠标移动监听。

来看看第二点,如何计算应该清空的区域?

如图,假设a、b是鼠标移动过程中经过的连续的两点,则需要清除的范围就是图中的矩形加上一个圆形(也可以不加上圆形)。

js 复制代码
// 新建一个数组,用于保存一次按下鼠标后檫除的路径。
let points = [];
let snapshot = null;
// 鼠标移动回调
function drawEraser(ev){
    ctx.putImageData(snapshot, 0, 0)
    points.push({ x: ev.offsetX, y: ev.offsetY })
    for (let i = 1; i < points.length; i++) {
      let sx = ctx.lineWidth * Math.sin(Math.atan((points[i - 1].y - points[i].y) / (points[i - 1].x - points[i].x)))
      let sy = ctx.lineWidth * Math.cos(Math.atan((points[i - 1].y - points[i].y) / (points[i - 1].x - points[i].x)))
      ctx.save()
      ctx.beginPath()
      ctx.moveTo(points[i - 1].x + sx, points[i - 1].y - sy)
      ctx.lineTo(points[i - 1].x - sx, points[i - 1].y + sy)
      ctx.lineTo(points[i].x + sx, points[i].y - sy)
      ctx.lineTo(points[i].x - sx, points[i].y + sy)
      ctx.closePath()
      ctx.clip()
      ctx.clearRect(0,0,canvas.width,canvas.height)
      ctx.restore()
      
      ctx.save()
      ctx.arc(points[i].x,points[i].y,ctx.lineWidth,0,2*Math.PI)
      ctx.clip()
      ctx.clearRect(0,0,canvas.width,canvas.height)
      ctx.restore()
    }
};
canvas.addEventListener("mousedown",(ev)=>{
    // 重置路径
    points = [];
    points.push({ x: ev.offsetX, y: ev.offsetY });
    // 保存当前画布内容
    snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
    canvas.addEventListener("mousemove",drawEllipse);
})
canvas.addEventListener("mouseup",(ev)=>{
    canvas.removeEventListener("mousemove",drawEllipse);
})

工具切换

仔细观察画笔工具、矩形工具、椭圆工具和檫除工具的使用方式竟如此相似!也因此,实现的代码也大致相同,鼠标按下与抬起事件的代码都几乎一样,不同的点只在于绑定的处理函数不同。因为鼠标移动事件是在鼠标按下时才绑定的,所以只要在绑定前修改回调函数,就能使用相同的代码实现上述的功能:

js 复制代码
let callback = null;
canvas.addEventListener("mousedown",(ev)=>{
    // 重置路径
    points = [];
    points.push({ x: ev.offsetX, y: ev.offsetY });
    // 保存当前画布内容
    snapshot = ctx.getImageData(0,0,canvas.width,canvas.height)
    canvas.addEventListener("mousemove",callback);
})
canvas.addEventListener("mouseup",(ev)=>{
    canvas.removeEventListener("mousemove",callback);
})

我们显示一些按钮,用来切换对应的功能:

html 复制代码
  <button onclick="brush(1)">画笔工具</button>
  <button onclick="brush(2)">矩形工具</button>
  <button onclick="brush(3)">椭圆工具</button>
  <button onclick="brush(4)">檫除工具</button>

根据点击的按钮,设置不同的回调函数。

js 复制代码
function brush(choice){
    switch (choice) {
      case 1:
        callback = drawLine
        break;
      case 2:
        callback = drawRect
        break;
      case 3:
        callback = drawEllipse
        break;
      case 4:
        callback = drawEraser
        break;

      default:
        break;
    }
}

文本工具

文本工具与上述工具不太一样,不需要监听鼠标按下、移动和松开事件,而是需要监听鼠标点击和输入内容。

使用步骤:鼠标点击,记录点击位置,输入文本内容,点击其他区域结束输入。

功能分析:监听鼠标点击事件,获取点击位置,在点击位置放一个输入框,聚焦于输入框,输入文本,当输入框失去焦点时隐藏输入框,将输入内容绘制到画板。

html 复制代码
  <button onclick="brush(5)">文本工具</button>
  <textarea class="input"></textarea>
css 复制代码
.input{
  display: none;
  position: absolute;
  left: 0;
  top: 0;
  resize: none;
  overflow: hidden;
  font-size: 24px;
  line-height: 24px;
  border: none;
}
.input:focus {
  outline: solid 1px #ff0000;
}
js 复制代码
 function brush(choice) {
    switch (choice) {
      case 1:
        callback = drawLine
        break;
      case 2:
        callback = drawRect
        break;
      case 3:
        callback = drawEllipse
        break;
      case 4:
        callback = drawEraser
        break;
      case 5:
        callback = null
        text()
        break;
      default:
        break;
    }
  }
  
  function text() {
    canvas.style.cursor = 'text'
    canvas.onclick = function (ev) {
      canvas.onclick = null
      canvas.style.cursor = 'default'
      // console.log(ev);
      let x = ev.offsetX
      let y = ev.offsetY
      let lineHeight = 24
      let whitespace = 20

      input.value = ''
      input.rows = 1
      input.style.display = 'block'
      input.style.left = ev.clientX + 'px'
      input.style.top = ev.clientY - lineHeight / 2 + 'px'
      input.style.width = whitespace + 'px'

      snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height)
      ctx.textBaseline = "top";
      ctx.font = "24px 仿宋"
      ctx.fillStyle = '#000000ff'

      // 输入时根据文本域内容修改文本域尺寸
      input.oninput = (ev) => {
        let text = input.value.split(/\n/g);
        let width = 0
        input.rows = text.length
        text.forEach((el, index) => {
          width = Math.max(ctx.measureText(el).width, width)
        });
        input.style.width = width + whitespace + 'px'
      }
      // 当文本域失去焦点,隐藏文本域,将文字绘制在画板上
      input.onblur = () => {
        input.style.display = 'none'
        let text = input.value.split(/\n/g);
        text.forEach((el, index) => {
          ctx.fillText(el, x, y - lineHeight / 2 + index * lineHeight)
        });
      }
      input.focus()
    }
  }

撤销

撤销功能,简单来说就是将画布还原到上一步的状态。这里提供两种思路:

  1. 记录每一步的操作,将操作压入栈中,每次撤销都弹出一个操作,然后清空画布,按栈中的操作再绘制一次。
  2. 记录每一次操作的画布状态,不关心之前进行过什么操作,每次撤销都直接读取画布的前一个状态。

我们现在用思路二来完成撤销功能。首先还是提供一个撤销按钮:

html 复制代码
  <button onclick="brush(6)">撤销</button>

前面我们声明了一个变量snapshot,用于保存当前的画布状态,及生成了一次快照,现在我们再声明一个变量用来保存这些快照。

js 复制代码
let shapshots = [];

然后是保存快照的时机:每次完成一个操作。

js 复制代码
canvas.addEventListener("mouseup", (ev) => {
  if(points[1]){
    snapshots.push(snapshot)
  }
  canvas.removeEventListener("mousemove", callback);
})
js 复制代码
  /* 文本工具 */ 
input.onblur = () => {
    input.style.display = 'none'
    let text = input.value.split(/\n/g);
    text.forEach((el, index) => {
      ctx.fillText(el, x, y - lineHeight / 2 + index * lineHeight)
    });
    if(input.value){
      snapshots.push(snapshot)
    }
 }

实现撤销功能

js 复制代码
function brush(choice) {
   switch (choice) {
     case 1:
       callback = drawLine
       break;
     case 2:
       callback = drawRect
       break;
     case 3:
       callback = drawEllipse
       break;
     case 4:
       callback = drawEraser
       break;
     case 5:
       callback = null
       text()
       break;
     case 6:
       if(snapshots.length>0){
         ctx.putImageData(snapshots.pop(),0,0)
       }
       break;
     default:
       break;
   }
}
相关推荐
亦黑迷失3 天前
水印的攻防战
前端·javascript·canvas
JieZhongBa13 天前
微信小程序canvas拖动卡顿问题解决方法
vue.js·微信小程序·uniapp·canvas
Naive_Jam14 天前
uniapp 微信小程序自定义分享图片
css·微信小程序·uni-app·canvas
valsedefleurs15 天前
用Canvas绘制2D平面近大远小的马路斑马线
前端·javascript·canvas
xachary17 天前
前端使用 Konva 实现可视化设计器(16)- 旋转对齐、触摸板操作的优化
前端·vue·canvas·konva
很甜的西瓜22 天前
程序员学习Processing和TouchDesigner视觉编程相关工具
开发语言·前端·javascript·图像处理·webgl·canvas
xachary23 天前
前端使用 Konva 实现可视化设计器(14)- 折线 - 最优路径应用【代码篇】
vue·canvas·最优路径·konva
吉吉安23 天前
使用canvas绘制图片蒙版/获取绘制区域坐标/转成base64
前端·vue.js·chatgpt·canvas