canvas : 搞个涂鸦板玩玩吧

1,936 阅读4分钟

前言

最近接了一个项目,里面有一个涂鸦板的模块,然后就顺便拿这个项目来进行知识点复习(上一次写的时候没有仔细检查一下这个项目,忽略了一个保证路径的完整性,现在把他添加到文章下方)

明确功能

先看一下成品图。这里因为主题的问题下面被截断了一点,但是原来的主题实在不好看,所以就将就一下吧。 在线地址

image.png

那从上图我们大概可以知道有下面这些功能需要实现

  • 支持涂鸦
  • 支持修改画笔颜色
  • 支持修改画笔大小
  • 支持上一步
  • 支持下一步
  • 支持添加背景图片
  • 支持生成图片

初始化

上面已经明确了我们需要完成一个什么样的涂鸦板了,那现在就先来初始化一个模版吧

<template>
  <div class="container">
    
      <!--  画板-->
      <div class="canvas-container">
        <h3>画板</h3>
        <canvas
              :width="760"
              :height="610"
              ref="myPalette"
              class="palette"
              @mousedown="handleDownCanvas"
              @mouseup="handleOverMove"
              @mousemove="handleMove"
              @mouseout="handleOverMove"
          />
          <img style="margin-left: 30px" :src="image" alt="">
      </div>

      <div>
        鼠标坐标x: {{movex}}y:{{movey}}
      </div>

      <div class="container-item">
        <button class="button-item" @click="handlePre">上一步</button>
        <button class="button-item" @click="handleNext">下一步</button>
        <button class="button-item" @click="handleSetImg">选择图片</button>
        <button class="button-item" @click="createImage">生成图片</button>
      </div>

      <div class="container-item">
        <h4>画笔颜色</h4>
        <span
            class="color-item"
            v-for="(item,index) in colors"
            :style="{'background':item}"
            @click="handleSetColor(item)"
            :key="index"
        />
      </div>

      <div class="container-item">
        <h4>画笔大小</h4>
        <div class="size-item" v-for="(item,index) in size" :key="index" @click="handleSetSize(item.size)">{{item.name}}</div>
      </div>

  </div>
</template>
import mixin from "./mixin"
export default {
  name: "palette",
  mixins:[mixin],
  data(){
    return{
      // 画笔颜色
      colors:[
        '#f1d506','#0924de','#08e31e','#f32f15','#cccccc','#5ab639'
      ],
      size:[
        {name:"小",size:1},
        {name:"中",size:2},
        {name:"大",size:3}
      ],
      // canvas对象
      context: {},
      // 保存绘画的路径
      lines:[],
      // 是否开始绘制
      canvasMoveUse: false,
      // 画笔的设置
      config:{
        lineWidth:1,              //  线条的宽度
        shadowBlur:1,             //  阴影模糊的程度
        shadowColor:"#f1d506",    //  阴影的颜色
        strokeStyle:"#f10649"     //  笔触的颜色
      },
      preHandle:[],   // 上一步
      nextHandle:[],   // 下一步
      movex:0,
      movey:0,
      image:null
    }
  },
}

一顿操作之后,页面展示应该如图:

image.png 当然现在的控制台应该很多报错,因为我们还没有将对应的函数等添加到方法中,接下来就开始完善各种功能,在开始之前,先将canvas添加到data中,方法我们之后进行调用

export default {
    ...
    mounted() {
        this.init()
    },
    methods:{
        init(){
           const canvas = this.$refs.myPalette
           this.context = canvas.getContext("2d")
        }
    }
}

开发功能模块

涂鸦功能实现

因为接下来的功能都是得在能涂鸦的情况下实现,所以最开始就得先实现这个最基础的功能啦。
在开始之前,首先得明确一下,canvas是如何做这个绘画的功能的呢?我们知道,当我们开始画图的时候,通常是从某一点到另外一点的线条,那也就表明了,其实我们做的涂鸦功能,也是从某一点(x,y)到另外一点(x,y)路径的绘制,知道了这个之后,就可以开始我们的操作了。

看一下初始化的代码,我们已经给canvas添加了mousedownmouseupmousemovemouseout,它们分别对应鼠标的按下,抬起,移动,移出元素,那我们就根据四个事件来完善涂鸦的功能。

鼠标按下时

知道了绘制是从一点到另外一点的路径之后,那当我们开始绘制的时候,需要一个起点,而这个起点其实就是鼠标按下时候的坐标点,那就得先拿到鼠标的坐标点啦,先看一下代码吧。

// 在canvas中按下鼠标
handleDownCanvas(e){
    // 是否可以开始移动绘制
    this.canvasMoveUse = true
    // 获取当前鼠标按下的位置
    const {canvasX,canvasY} = this.getEventXY(e)
    // 重置画笔配置
    this.handleSetConfig()
    // 清除子路径
    this.context.beginPath()
    // 记录起点
    this.context.moveTo(canvasX, canvasY)
    // 参数的值 x y width height
    const pre = this.context.getImageData(0, 0, 700, 600)
    // 记录当前操作,便于后续的撤销操作
    this.preHandle.push(pre)
    // 重新绘画之后清除所有下一步
    this.nextHandle = []
},

然后逐步来说明一下每个模块代码的作用
canvasMoveUse

  • 这个变量主要的作用就是用来决定是否要开始绘制路径
    getEventXY()
  • 获取鼠标按下或者移动的时候的坐标点
    在这里得先来了解一下最基本的获取坐标的知识。 看一下点击或者移动的时候,获取到的当前对象

image.png 在这个里面我们需要先了解一下几个属性值表示的意思

  • clienX/Y: 当鼠标事件发生时,鼠标相对于浏览器的X或Y轴距离
  • offsetX/Y:当鼠标事件发生时,鼠标相对于事件源X或Y轴的位置
  • screenX/Y:当鼠标事件发生时,鼠标相对于显示器屏幕X或Y轴的位置

用图示就是

image.png 还有一点就是,在PC端获取坐标点跟在手机端获取的方式有些差异,但是目前这个涂鸦板只考虑PC,所以手机端就暂时不说,有兴趣可以百度一下
了解完这些之后再来看一下获取鼠标坐标点的函数,就会清晰很多了

getEventXY(e){
    // 默认获取pc
    let canvasX = e.offsetX
    let canvasY = e.offsetY
    this.movex =  canvasX
    this.movey =  canvasY
    // 使用手机的时候
    if(!this.isPC()){
        canvasX = e.changedTouches[0].offsetX
        canvasY = e.changedTouches[0].offsetY
    }
    return {canvasX,canvasY}
},

完成之后,在点击移动之后,下面的鼠标坐标也会出现相应的坐标点。
handleSetConfig

  • 设置画笔,设置为config中的参数,而config的颜色默认的设置为颜色阴影数组中的第一个 beginPath
  • 清除绘画的路径,如果不添加这个参数,每次按下进行绘制的时候,都会被认为是在同一条路径上进行绘制,那这样的话就会导致路线全部连在一起,所有的颜色都会变成你最后选择的颜色 moveTo
  • 设置绘制开始的起点 getImageData
  • 生成当前的canvas的图像,记录下来,方便后面进行上一步的操作

鼠标抬起,移出

这两个就没什么特别好说的了,主要就是因为抬起移出的时候,如果不清除掉移动,那就会导致还可以继续进行绘制

// 结束绘画
handleOverMove(){
  this.canvasMoveUse = false
},

鼠标移动时

// 移动
handleMove(e){
  if (!this.canvasMoveUse) return
  // 获取坐标点
  const {canvasX,canvasY} = this.getEventXY(e)
  // 链接每个点
  this.context.lineTo( canvasX ,canvasY)
  //绘制已定义的路径
  this.context.stroke()
}

这个最主要就是连接点跟点,绘制成线,其他的都是canvas的内容,具体的api调用直接上文档吧 canvas
到这里最基础的涂鸦功能就完成,现在尝试一下绘制,不出意外就没问题啦

支持修改画笔颜色,大小

之前已经有config这个配置参数了跟handleSetConfig这个设置画笔的配置函数了,那修改大小跟颜色其实就是修改config的参数,然后调用一下handleSetConfig就行了。

// 设置画笔的颜色
handleSetColor(color){
  this.config.shadowColor = color  // 阴影
  this.config.strokeStyle = color  // 画笔颜色
  this.handleSetConfig()
},

// 设置画笔大小
handleSetSize(size){
  this.config.lineWidth = size
  this.handleSetConfig()
},

支持上一步,下一步

上一步的功能,其实就是把当前画布上的内容重置为上一次画布上的内容,在完善涂鸦功能的时候已经把当前的画布内容保存下来了。

handleDownCanvas(e){
    ...
    // 参数的值 x y width height
    const pre = this.context.getImageData(0, 0, 700, 600)
    // 记录当前操作,便于后续的撤销操作
    this.preHandle.push(pre)
}

然后完善一下上一步的操作,在这里的时候,因为我们把他压进数组的时候,是先进后出的概念,所以需要从数组的最底部拿到上一次更新的内容,然后将当前的画布的内容,作为下一步的数据存进nextHandle数组中,然后更新到画布上就可以了。

 // 上一步
handlePre(){
  if(!this.preHandle.length) return false
  const pre =  this.preHandle.pop()
  // 这里应该是把当前的canvas保存进下一步
  const next = this.context.getImageData(0, 0, 760, 610)
  this.nextHandle.push(next)
  this.context.putImageData(pre,0, 0)
},

下一步的功能跟上一步是一样的,不同的时候这里需要将当前的画布内容存进上一步

 // 下一步
handleNext(){
  if(!this.nextHandle.length) return false
  const next = this.nextHandle.pop()
  const pre = this.context.getImageData(0, 0, 760, 610)
  this.preHandle.push(pre)
  this.context.putImageData(next,0, 0)
}

这样上一步下一步的功能也就完成了

支持添加背景图片,生成图片

添加背景图片这里有一个麻烦的点,就是添加到画布之后,之前绘画的内容就被覆盖掉了,所以我这里处理的方法是将每次绘制的路径参数都保存了下来,等图片添加完成之后,将之前绘制过的复原回去,这是目前我能想到的方案。
所以得在之前的handleDownCanvas,handleMove,handleOverMove函数中添加一下操作

handleDownCanvas(e){
    ...
    // 按下就保存路径位置
    this.lines.push({
        x:canvasX,
        y:canvasY,
        strokeStyle:this.context.strokeStyle,
        shadowColor:this.context.shadowColor
    })
},
handleMove(e){
    // 保存路径位置
    this.lines.push({
        x:canvasX,
        y:canvasY,
        strokeStyle:this.context.strokeStyle,
        shadowColor:this.context.shadowColor
    })
},
handleOverMove(){
    // 往记录中添加断点
    this.lines.push(null)
}

然后先看一下整体的代码吧,

// 选择图片设置
handleSetImg(){
  let input = document.createElement("input")
  input.type = 'file'
  input.accept = 'image/*'
  input.onchange = this.putImageToCanvas
  input.click()
},  
// 更新到canvas
putImageToCanvas(event){
  const e = event.target;
  const { files } = e; // 拿到所有的文件
  const file = files[0]

  let reader = new FileReader()
  reader.readAsDataURL(file)
  reader.onload = () => {
    // console.log('file 转 base64结果:' + reader.result)
    let imag = new Image();
    imag.src = reader.result
    imag.onload = () =>{
      const  {clientWidth,clientHeight} = this.$refs.myPalette
      // 绘制之前还是需要将当前页面添加到上一步
      this.preHandle.push(this.context.getImageData(0, 0, 760, 610))
      this.context.drawImage(imag,0,0,clientWidth,clientHeight)
      // 这里没办法解决画图被覆盖的问题,只能绘制完图片之后将线条绘制回去
      this.resetLine()
    }
  }
},
// 重新绘制之前绘画
resetLine(){
  this.context.beginPath();
  // 这里是将绘制的记录返回回来,但是这里返回之后,就没法再进行上下了
  this.lines.forEach((item,index) => {
    // item === null 代表着抬起手指,断开绘制
    if (item){
      const next_item = this.lines[index+1] ||  item
      this.context.moveTo(item.x,item.y);
      this.context.lineTo(next_item.x,next_item.y);
      this.context.strokeStyle=item.strokeStyle;
      this.context.shadowColor=item.shadowColor
      this.context.stroke();
    }else{
      // 清除子路径
      console.log('清除子路径')
      this.context.beginPath();
    }
  })
},

handleSetImg
选择图片,老生常谈的操作了,就没啥好说了
putImageToCanvas
这里是将file类型转换为base64,因为不这样做的话,图片加载不出来,然后再进行原来路径的绘制,在这里同样需要把当前的画布内容添加到上一步
resetLine
重新绘制之前绘画,这里需要注意的点就是因为在绘制的时候会有断开的行为,所以在判断到当前的item === null的时候,直接调用beginPath()清除子路径操作,然后继续下一步的绘制就行了。

最后就是生成图片了,这个也没啥好说的,直接上代码吧

 // 生成图片
createImage(){
  this.image = this.$refs.myPalette.toDataURL("image/png",1)
  console.log("生成图片")
},

保证路径规划整体性

上面的功能其实还缺少一个,就是保证路径的完整性,因为我们在之前为了保证添加图片之后可以将之前绘制的画更新到背景图片上,添加了lines这个参数来保存了路线,但是在上一步,下一步的时候,也应该相应的从lines中删除或者添加撤销的路径的数据,所以我们先整理一下我们是怎么进行上一步下一步储存的,还是加图片来讲解最好

image.png

那从图里面我们就可以知道,每次点击上一步的时候,都是从上一步的底部拿数据,然后添加到下一步的底部,那反过来,每次点下一步,将数据添加到上一步数组中,是不是顺序其实是一样的?用简单的代码表示

let pre = [1,2,3];
let next = [];
next.push(pre[2]);
pre.push(next.pop())
console.log(pre[2]) // 3

这样就能少做很多工作了,至少不用再去担心点击上一步的时候,复原的时候,数据是否会错乱的问题,那接下来,就来开始完善一下功能

修改按下鼠标函数

在原来的结构上添加preKey属性,他的主要作用就是用来表示当前保存下来的这段路径是属于哪一次操作的,对应的就是preHandle数组中的key.

handleDownCanvas(e){
    // 添加到上一步操作的最后一步就是当前的key
    const preKey = this.preHandle.length - 1
    // 按下就保存路径位置
    this.lines.push({
        preKey,
        x:canvasX,
        y:canvasY,
        strokeStyle:this.context.strokeStyle,
        shadowColor:this.context.shadowColor
    })
}

修改上一步函数

 // 上一步
handlePre(){
  if(!this.preHandle.length) return false
  const preKey =  this.preHandle.length - 1 //  新加
  const pre =  this.preHandle.pop()
  // 这里应该是把当前的canvas保存进下一步
  const next = this.context.getImageData(0, 0, 760, 610)
  // 修改结构为 当前的key,跟数据
  const nextData = {preKey,data:next,lines:[]} // 新加
  this.nextHandle.push(nextData)
  // 删除对应的绘制路径
  this.deleteLines(preKey) // 新加
  this.context.putImageData(pre,0, 0)
},

上面最主要是新添加了三段语句:
1 const preKey = this.preHandle.length - 1,
这里其实就是拿来当前最后一个元素的key
2 const nextData = {data:next,lines:[]},
这里多了一个lines数组,这个数组就是用来保存本次被撤销的路线的数据
3 this.deleteLines(preKey)
这里是本次完善的主要的功能之一:删除对应的路径信息,这里就需要之前在按下时保存的preKey这个属性了, 整体的思路,就是将找到preKey在lines数组中的位置,从确定的位置中开始删除数据,并且放进本次属于这个preKey的nextHandle的lines数组中,结束条件是遇到下一次的操作出现的prekey,就中断本次操作

deleteLines(preKey){
    // 查找当前preKey在记录中哪里开始
    const linesKey =  this.lines.findIndex(n => n  && n.preKey === preKey)
    // 将路径参数添加到对应的nextHandle的数组中
    const nextArrIndex =  this.nextHandle.findIndex(n=>n&&n.preKey === preKey)
    if (linesKey === -1) return false
    let i = linesKey
    let saveData = []
    // 删除当前位置到下一次遇到PreKey
    for (i;i<this.lines.length;i++){
        if(!this.lines[i])  {
            saveData.push(null)
            continue
        }
        let flag = this.lines[i] !== null &&
            this.lines[i].preKey !== void(0) &&
            this.lines[i].preKey !== preKey
        // 找到下一次的preKey就结束
        if(flag) break;
        saveData.push(this.lines[i])
    }

    this.$nextTick(()=>{
        // 删除
        this.lines.splice(linesKey,i)
        // 添加绘制路径到下一步
        this.nextHandle[nextArrIndex].lines = saveData
    })
},

修改下一步函数

这个就比较简单了,因为之前已经把路径的数据添加到nextHandle中了,那这里只需要将对应的路径数据添加到lines中就可以了,这里就不在原来的位置插入数据了,因为无论在哪个地方开始绘制,只要这条路径的参数(x,y)不错,最终画出来就是一样的,只是前后的问题

// 下一步
handleNext(){
  if(!this.nextHandle.length) return false
  const next = this.nextHandle.pop()
  // 这里应该是把当前的canvas保存进下一步
  const pre = this.context.getImageData(0, 0, 760, 610)
  this.preHandle.push(pre)
  this.context.putImageData(next.data,0, 0)
  // 将路径数据返回到lines里面
  next.lines.forEach((item) => {
    this.lines.push(item)
  })
},

把这个功能也完善之后,大概正常用就没什么特别大的问题啦,其他问题跟功能的完善,就等之后有时候再重新出续集吧。

结束

现在上面说明的功能都已经完成了,具体的代码在github,答应我,点个🌟再走好吗