利用Canvas实现手写板插件

此插件基于Vue。每一大章节都有对应的源码和DEMO。

前两天在公司项目上遇到了一个签署合同的需求,详细是在PAD端实现手写板完成名字签署,不需要考虑笔画粗细、颜色等。效果如下图所示。

效果图

实现起来很简单,于是就自己写了一个小插件。此篇文章就在此基础上对笔画粗细、颜色等加以扩展,写一个移动端的手写板。

实现原理

对touch和canvas有所了解的可以略过本节。

在画板上写字,整个写字的过程大概分为三部分,分别为落笔、运笔、提笔(自己瞎概括的)。这三个步骤分别对应了三个移动端的监听方法。其中

  • 落笔对应的为:touchstart,此方法当手指触摸到屏幕时触发。
  • 运笔对应的为:touchmove,此方法当手指在屏幕上滑动时触发。
  • 提笔对应的为:touchend,此方法当手指离开屏幕时触发。

运行这三个方法就可以模拟写字的整个过程。
我想应该学会了如何在移动端控制自己手指触控屏幕的过程,但是现在还没有笔迹,没有画板,所以我们需要先通过canvas构建一个画板,然后生成笔迹。下面介绍一下这个插件所需要用到的canvas相关方法。

Canvas

  • 方法
  1. getContext('2d') 获取一个context 2d对象,即渲染上下文,其中有很多画图相关的方法,对应的还有3d对象。
  2. beginPath() 开始绘制路径
  3. lineTo() 线段的终点
  4. moveTo() 线段的地点
  5. stroke() 给线段上色
  6. closePath() 结束路径绘制
  7. clearRect(x1, y1, x2, y2) 清空一定范围内的内容
  8. toDataURL() 将Canvas数据重新转化成图片文件
  • 线条相关属性
  1. strokeStyle 线条颜色
  2. lineWidth 线条宽度
  3. lineCap 线条结束线帽
    • butt:默认值。平直边缘
    • round:圆形线帽
    • square:正方形线帽
  4. lineJoin 线条转弯处的线帽
    • 值类型同上。

插件编写

搭架子

首先我们先搭起一个大的架子。其中需要通过props传入的值均先由data替代。

<template>
  <div class="hand-writing">
      <canvas 
        ref="writingCanvas" 
        class="writing-box" 
        :width="canvasWidth" 
        :height="canvasHeight" 
        @touchstart="onStart"
        @touchmove="onMove" 
        @touchend="onEnd">
      </canvas>
      <div class="btn-box">
        <div class="btn btn-clear" @click="onClear">清屏</div>
        <div class="btn btn-generate" @click="onGenerate">生成</div>
      </div>
  </div>
</template>

<script>

export default {
  name: 'HandWriting',
  data: function() {
    return {
      // 画板坐标
      offsetWidth: 0,
      offsetHeight: 0,
      // 画板宽度
      canvasWidth: '',
      // 画板高度
      canvasHeight: '',
      // 线条宽度
      lineWidth: 10,
      // 线条颜色
      lineColor: '#000',
    };
  },
  mounted() {
      this.init()
  },
  methods: {
      // 画板初始化
      init () {},
      // 开始触摸
      onStart (e) {},
      // 移动
      onMove (e) {},
      // 停止触摸
      onEnd (e) {},
      // 点击取消
      onClear () {},
      // 点击确认
      onGenerate () {},
  },
}
</script>

<style lang="css" scoped>
.hand-writing {
    width: 100%;
    height: 100%;
    background: #fff;
}

.writing-box {
    display: block;
    margin: 0 auto;
    width: 100%;
    height: 80%;
    background: #ccc;
}
.btn-box {
  margin: 0 auto;
  width: 300px;
  height: 20%;
}
.btn {
  box-sizing: border-box;
  margin: 20px 25px;
  display: inline-block;
  width: 100px;
  height: 50px;
  border: 1px solid #1890ff;
  border-radius: 10px;
  background: #1890ff;
  color: #fff;
  text-align: center;
  line-height: 50px;
}

.btn:active {
  background: #fff;
  color: #000;
}
</style>

这样一个架子就搭完了,效果如下图所示。

架子效果图

初始化

然后我们开始进入整体,首先需要获取canvas的context对象。在init()方法中添加如下代码

init () {
  // 获取canvas
  const canvas = this.$refs.writingCanvas
  // 显式的宽高赋值
  this.canvasWidth = canvas.offsetWidth
  this.canvasHeight = canvas.offsetHeight
  // 获取context对象
  this.ctx = canvas.getContext('2d')
},
onStart

获取到了context对象就可以开始画东西了。在onStart()方法中添加如下代码

onStart (e) {
  // 获取画板相对于屏幕的偏移量,即左上角的坐标
  this.offsetLeft = e.target.offsetLeft
  this.offsetTop = e.target.offsetTop
  // 获取点击点的坐标(实际坐标 = 点击点相对于屏幕的坐标 - 画板相对于屏幕的坐标)
  let x = e.touches[0].clientX - this.offsetLeft
  let y = e.touches[0].clientY - this.offsetTop
  // 开始绘制
  this.ctx.beginPath()
  // 设置线条属性
  this.ctx.lineWidth = this.lineWidth
  this.ctx.strokeStyle = this.lineColor
  this.ctx.lineCap = 'round'
  this.ctx.lineJoin = 'round'
  // 绘制点击点
  this.ctx.lineTo(x, y)
  this.ctx.stroke()
},
onMove

现在调试这个DEMO,就可以发现已经可以在画板中点击画点了。下面开始让这个点移动起来。在onMove()方法中添加如下代码

onMove (e) {
  // 获取点击点的坐标
  let x = e.touches[0].clientX - this.offsetLeft
  let y = e.touches[0].clientY - this.offsetTop
  // 绘制
  this.ctx.lineTo(x, y)
  this.ctx.stroke()
},
onEnd

现在再调试,已经可以正常的画线了,但是有始有终,当我们停止手指触摸时,应该关闭路径绘制。在onEnd()方法中添加如下代码

onEnd () {
  // 停止绘制
  this.ctx.closePath()
},
onClear

到此画图的部分已经做完了,下面我们来实现清除画板功能和生成图片功能。首先是清除功能。在onClear()方法中添加如下代码

onClear () {
  // 清空画板
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
},
onGenerate

然后来添加生成图片的功能。在onGenerate()方法中添加如下代码

onGenerate () {
  const filePath = this.$refs.writingCanvas.toDataURL()
  console.log(filePath)
},
图片显示

点击生成按钮就可以在控制台看到打印的png类型的图片的base64地址。现在让我们将其显示在屏幕上。修改整体代码如下

<template>
  <div class="hand-writing">
      <img v-if="filePath" :src="filePath" alt="">
      <canvas 
        v-else
        ref="writingCanvas" 
        class="writing-box" 
        :width="canvasWidth" 
        :height="canvasHeight" 
        @touchstart="onStart"
        @touchmove="onMove" 
        @touchend="onEnd">
      </canvas>
      <div class="btn-box">
        <div class="btn btn-clear" @click="onClear">清屏</div>
        <div class="btn btn-generate" @click="onGenerate">生成</div>
      </div>
  </div>
</template>

<script>

export default {
  name: 'HandWriting',
  data: function() {
    return {
      ...
      
      // 图片地址
      filePath: '',
    };
  },
  mounted() {
      this.init()
  },
  methods: {
      
      ...

      // 点击取消
      onClear () {
        this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
        this.filePath = ''
        // 清空filePath会造成DOM更新,而DOM更新会有延迟,所以放在nextTick中在DOM更新完后执行
        // 清空filePath会让canvas重新生成,所以需要重新初始化
        this.$nextTick(() => {
          this.init()
        })
      },
      // 点击确认
      onGenerate () {
        // 当生成之后再点击将无效
        this.filePath = this.filePath ? this.filePath : this.$refs.writingCanvas.toDataURL()
      },
  },
}
</script>

代码优化

可以看到刚刚写的代码非常冗余,让我们优化一下。可以将所有的绘制提取出来,如下

// 将onStart 和 onMove中的公共代码提取出来
handleDraw (e) {
  // 获取点击点的坐标
  let x = e.touches[0].clientX - this.offsetLeft
  let y = e.touches[0].clientY - this.offsetTop
  // 绘制
  this.ctx.lineTo(x, y)
  this.ctx.stroke()
},

作为一个插件我们需要提供一些暴露给外部的方法,变量,以及需要接收一些变量,更改如下

export default {
  name: 'HandWriting',
  props: {
    path: {
      type: String,
      default: '',
    }
  },
  
  ...

  methods: {
      // 画板初始化
      init () {
        
        ...

        if (this.path !== '') {
          this.filePath = this.path
        }
      },
      
      ...

      // 点击取消
      onClear () {

        ...

        // 清除的回调
        this.$emit('onClear')
      },
      // 点击确认
      onGenerate () {
        if(this.filePath) {
          this.filePath = this.filePath
        } else {
          this.filePath = this.$refs.writingCanvas.toDataURL()
          // 生成图片的回调
        this.$emit('onComplete', this.filePath)
        }
      },
  },
}

这里props只接收了图片,当然也可以接收canvas的宽度、高度等。方法都一样,就不再赘述。

在线预览&源码

预览请打开控制台在手机模式下运行。笔画位置有误差请刷新浏览器。
DEMO

源码:GitHub

功能扩展

此章节开始所有的ui及样式将使用Ant Design Vue。之前的代码无需更改,并无冲突。

更改画笔粗细

更改笔画粗细首先需要一个调节笔画粗细的组件。这里使用了antd的气泡卡片作为调节的容器。使用滑动输入条调节粗细。在文件中添加、更改如下代码

<template>
  <div class="hand-writing">
    <img v-if="filePath" :src="filePath" alt="" />
    <canvas
      v-else
      ref="writingCanvas"
      class="writing-box"
      :width="canvasWidth"
      :height="canvasHeight"
      @touchstart="onStart"
      @touchmove="onMove"
      @touchend="onEnd"
    >
    </canvas>
    <div class="btn-box">
      <!-- 添加开始 -->
      <!-- 调节弹框 -->
      <a-popover v-model="adjustVisible">
        <template slot="title">
          <div class="adjust-header">
            <span>调节笔画</span>
            <a-icon type="close-circle" @click="adjustVisible = false" />
          </div>
        </template>
        <template slot="content">
          <div class="adjust-content">
            <a-row>
              <a-col :span="4">线条粗细</a-col>
              <a-col :span="12">
                <a-slider :min="1" :max="20" v-model="lineWidth" />
              </a-col>
            </a-row>
          </div>
        </template>
        <a-button
          type="primary"
          class="btn btn-adjust"
          @click="adjustVisible = true"
        >
          调节笔画
        </a-button>
      </a-popover>
      <!-- 添加结束 -->
      <a-button type="primary" class="btn btn-clear" @click="onClear"
        >清屏</a-button
      >
      <a-button type="primary" class="btn btn-generate" @click="onGenerate"
        >生成</a-button
      >
    </div>
  </div>
</template>

<script>
export default {
  name: "HandWriting",
  props: {
    path: {
      type: String,
      default: ""
    }
  },
  data: function() {
    return {
      ...

      // 添加卡片隐藏控制变量
      adjustVisible: false
    };
  },
  ...
};
</script>

<style lang="css" scoped>

...

.btn-box {
  margin: 0 auto;
  padding-top: 10px;
  height: 20%;
}
.btn {
  margin: 0 15px;
  width: 100px;
  height: 50px;
  line-height: 50px;
}

.adjust-header {
    display: flex;
}

.adjust-header span {
    flex: 1;
}

.adjust-header .anticon{
    flex: 1;
    text-align: right;
    line-height: 21px;
}
</style>

更改之后,点击调节笔画,就可以在弹框中调节粗细了。

更改画笔颜色

这里使用vue-color调色板来调节颜色。

首先安装vue-color。

npm i vue-color
or
yarn add vue-color

然后在文件中引入,这里使用的Chrome样式的调色板。

import { Chrome } from 'vue-color'

...

data() {
  return {
    pickerColor: {},
  }
},
components: {
  "chrome-picker": Chrome
},

...

<chrome-picker v-model="pickerColor" />

直接通过这个插件获取到的值是一个对象,我们需要处理这个对象。添加如下代码,当调色板颜色发生变化时,改变线条颜色。

watch: {
  pickerColor: function(now) {
    this.lineColor = now.hex8
  }
},

添加完成之后,就可以开始愉快的更改画笔颜色,但是有点丑,让我们稍微修改一下样式。在文件中添加一些代码。

在template中,添加以下代码。其中整个线条的颜色的选择器放在了一个浮动的div里面,没有继续使用popover是因为,内层的关闭会造成外层同时关闭。所以自己写了一个类似的。

<a-popover
  v-model="adjustVisible"
  :arrowPointAtCenter="true"
>
  <template slot="title">
    <div class="adjust-header">
      <span>调节笔画</span>
      <a-icon type="close-circle" @click="adjustVisible = false" />
    </div>
  </template>
  <template slot="content">
    <div class="adjust-content">
      <a-row>
        <a-col :span="8">线条粗细</a-col>
        <a-col :span="16">
          <a-slider :min="1" :max="20" v-model="lineWidth" />
        </a-col>
      </a-row>
      <a-row>
        <a-col :span="8">线条颜色</a-col>
        <a-col :span="16">
          <div
            class="color-body"
            :style="{ background: lineColor }"
            @click="colorVisible = true"
          ></div>
          <div class="picker-box" v-if="colorVisible">
            <a-icon
              class="picker-cancel"
              type="close-circle"
              @click="colorVisible = false"
            />
            <chrome-picker v-model="pickerColor" />
          </div>
        </a-col>
      </a-row>
    </div>
  </template>
  <a-button
    type="primary"
    class="btn btn-adjust"
    @click="adjustVisible = true"
  >
    调节笔画
  </a-button>
</a-popover>

因为popover是直接挂载在 body 上的,所以我们还需要将其挂载在当前组件的根节点上才可以改变器样式。首先在根节点上注册一个ref。

<div ref="box" class="hand-writing">
  ...
</div>

然后写一个方法,返回注册好的节点

handleGetContainer() {
  return this.$refs.box;
}

然后在popover上使用此方法,就可以更改popover的样式了。

<a-popover
  :getPopupContainer="handleGetContainer"
>
  ...
</a-popover>

添加如下样式代码。


.adjust-header {
    display: flex;
    width: 188px;
}

.adjust-header span {
    flex: 1;
}

.adjust-header .anticon{
    flex: 1;
    text-align: right;
    line-height: 21px;
}
.ant-row {
    line-height: 36px;
}
.color-body {
    margin-left: 5px;
    vertical-align: sub;
    display: inline-block;
    width: 15px;
    height: 15px;
    border-radius: 50%;
    background: #000;
}
.picker-box {
  position: absolute;
  padding: 10px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  bottom: 30px;
  left: 10px;
}
.picker-box .picker-cancel {
  float: right;
  height: 20px;
  line-height: 20px;
}

.picker-box .vc-chrome {
  box-shadow: 0 0 0;
}

然后看看效果就会发现好很多了,虽然还是有点丑,就不再继续优化了。

橡皮檫功能

橡皮檫的实现类似于清除功能,不同的是,清除是擦除整个屏幕,而橡皮檫是擦除某一部分。一样的需要用到 onStart、 onMove、 onEnd 三个方法。所以我们需要一个新变量来存储当前鼠标(也就是手指?我也不知道叫什么合适。。。)的状态,判断当前究竟是画笔还是橡皮擦。不同的状态执行不同的方法。
首先在data中添加一个变量 mouseStatus: "brush"
然后在template中添加一个按钮控制其变化

<a-button
  type="primary"
  class="btn btn-switch"
  @click="mouseStatus = mouseStatus === 'brush' ? 'eraser' : 'brush'"
>
  切换为{{ mouseStatus === "brush" ? "橡皮檫" : "画笔" }}
</a-button>

代码优化

然后为了避免代码的冗余,我们需要对代码进行一次大换血,首先定义以下几个方法。

/**
 * 
 * 通过mouseStatus的值以及鼠标当前的动作阶段来判断该执行哪个方法
 * e: event
 * type: 鼠标当前的动作阶段(start、move、end)
 *
 */
handleSelectTouch(e, type) {}
/**
 * 
 * 画笔画图三步
 *
 */
onBrushStart(e) {}
onBrushMove(e) {}
onBrushEnd() {}
/**
 * 
 * 橡皮檫三步
 *
 */
onEraserStart(e) {}
onEraserMove(e) {}
onEraserEnd() {}
/**
 * 
 * 擦除方法,擦除以点击点为圆心,画笔宽度为直径的圆
 * x: 点击点x坐标
 * y: 点击点y坐标
 * radius: 半径
 *
 */
clearArc(x, y, radius) {}
/**
 * 
 * 擦除辅助方法,将擦出点连接起来
 * e: event
 * radius: 半径
 *
 */
clearLine(e, radius) {}

然后我们需要将以下几个方法的内容移至新方法内,并删除。

onStart() -> onBrushStart()
onMove() -> onBrushMove()
onEnd() -> onBrushEnd()

更改部分html代码如下

<!-- 将判断是画笔还是橡皮檫的代码全部放在一个方法里,方便处理 -->
<canvas
  ...
  @touchstart="handleSelectTouch($event, 'start')"
  @touchmove="handleSelectTouch($event, 'move')"
  @touchend="handleSelectTouch($event, 'end')"
>
</canvas>

添加判断画笔属性的代码

handleSelectTouch(e, type) {
  let _data = this.getInitialCapital(this.mouseStatus);
  switch (type) {
    case "start":
      this[`on${_data}Start`](e); // 这种调用方法看习惯了就好了
      break;
    case "move":
      this[`on${_data}Move`](e);
      break;
    case "end":
      this[`on${_data}End`](e);
      break;
    default:
      break;
  }
},
// 将字符串转换为首字母大写的形式
getInitialCapital(val) {
  return val.replace(/\S/, item => item.toUpperCase());
}

这时候在橡皮檫的三步方法里添加打印代码后,通过改变画笔属性就可以看到不同的效果了。接下来进入擦除的正题。

擦除圆形

在 canvas 的API里面我们发现擦除的方法只有一个 clearRect,而且此方法只能擦除一个矩形,而不能擦除其他图形,这不符合我们的想法。我们只能通过其他方法曲线救国。也就是 clip,clip 是 Canvas 2D API 将当前创建的路径设置为当前剪切路径的方法。也就是说我们可以先画一个圆,然后将这个圆设置为剪切路径,然后使用 clearReact 方法将其擦除。添加如下代码

onEraserStart(e) {
  this.offsetLeft = e.target.offsetLeft;
  this.offsetTop = e.target.offsetTop;
  this.c1px = e.touches[0].clientX - this.offsetLeft;
  this.c1py = e.touches[0].clientY - this.offsetTop;
  this.clearArc(this.c1px, this.c1py, this.lineWidth / 2);
},
onEraserMove(e) {
  this.offsetLeft = e.target.offsetLeft;
  this.offsetTop = e.target.offsetTop;
  this.c1px = e.touches[0].clientX - this.offsetLeft;
  this.c1py = e.touches[0].clientY - this.offsetTop;
  this.clearArc(this.c1px, this.c1py, this.lineWidth / 2);
},
onEraserEnd() {
  console.log("end");
},
clearArc(x, y, radius) {
  this.ctx.save();
  this.ctx.beginPath();
  // 画圆,以点击点为圆心坐标,线条的宽度为直径画圆
  this.ctx.arc(x, y, radius, 0, 2 * Math.PI);
  // 设置为剪切路径
  this.ctx.clip();
  // 擦除
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  this.ctx.restore();
},

这时就可以正常的使用橡皮擦了,但是当鼠标移动快一点的时候就会发现擦除的路径是不连续的,造成这种问题的原因是,快速移动的时候,只会画一个圆,但是圆与圆之间的路径是不会被画上的。下面,让我们来优化一下这个橡皮擦。

优化橡皮擦

因为画笔的宽度在使用的途中是固定的,也就是说我们在画路径的时候只需要将两个圆之间的矩形也画上就可以了。如下图所示。

矩形路径

画这个矩形我们需要知道矩形的四个顶点,但是目前我们只知道其中两条边的中点,也就是两个圆的圆心,我们需要计算以下这四个顶点。
首先,我们在上图中添加几条辅助线。如下图所示。

路径辅助线

可以看出我们需要得出四个顶点的坐标就必须先求得顶点与圆心的所在的直角三角形的两条直角边的长度。非常简单的相似三角形,有多种方法可以解决,这里使用的是三角函数。方法如下

let sinX = Math.sin(Math.atan((y2 - y1) / (x2 - x1)))
let cosY = Math.cos(Math.atan((y2 - y1) / (x2 - x1)))

借助上面的图,可以很轻松的理解这两个计算式。得到了两条直角边的长度,剩下的就是计算四个顶点坐标,然后画矩形了。添加及更改如下代码

onEraserStart(e) {
  this.offsetLeft = e.target.offsetLeft;
  this.offsetTop = e.target.offsetTop;
  this.c1px = e.touches[0].clientX - this.offsetLeft;
  this.c1py = e.touches[0].clientY - this.offsetTop;
  // 在矩形起点画圆
  this.clearArc(this.c1px, this.c1py, this.lineWidth / 2);
},
onEraserMove(e) {
  this.clearLine(e, this.lineWidth / 2);
},
onEraserEnd() {
  console.log("end");
},
clearArc(x, y, radius) {
  this.ctx.save();
  this.ctx.beginPath();
  this.ctx.arc(x, y, radius, 0, 2 * Math.PI);
  this.ctx.clip();
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  this.ctx.restore();
},
clearLine(e, radius) {
  this.offsetLeft = e.target.offsetLeft;
  this.offsetTop = e.target.offsetTop;
  let endX = e.touches[0].clientX - this.offsetLeft;
  let endY = e.touches[0].clientY - this.offsetTop;
  // 在矩形重点画圆
  this.clearArc(endX, endY, radius);

  // 计算辅助边长
  let sinX =
    radius * Math.sin(Math.atan((endY - this.c1py) / (endX - this.c1px)));
  let cosY =
    radius * Math.cos(Math.atan((endY - this.c1py) / (endX - this.c1px)));
  this.ctx.save();
  // 画矩形
  this.ctx.beginPath();
  this.ctx.moveTo(this.c1px - sinX, this.c1py + cosY);
  this.ctx.lineTo(this.c1px + sinX, this.c1py - cosY);
  this.ctx.lineTo(endX + sinX, endY - cosY);
  this.ctx.lineTo(endX - sinX, endY + cosY);
  this.ctx.closePath();
  this.ctx.clip();
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  this.ctx.restore();
  this.c1px = endX;
  this.c1py = endY;
},

细心的可以看到在方法中有这样两句代码 this.ctx.save(); 以及 this.ctx.restore();,第一句代码是保存当前的绘图环境,第二句是重置当前的绘图环境,如果不加这两句的话,在使用完橡皮擦后,绘图会被限制在剪切范围内。

撤销&还原

撤销和还原应该算是一个画图板的基本功能,这里就来实现一下。主要思路是定义一个栈(就是数组)来放置每一次更改画板的状态,然后通过一个指针(就是数组下标)来确定当前所在的状态。撤销操作就是将指针移至上一个状态(数组下标减一),还原操作就是将指针移至下一个状态(数组下标加一)。现在来实现这个思路,首先我们需要两个按键来帮助我们实现这两个功能。添加如下代码。

<a-button type="primary" class="btn btn-undo" @click="onUndo">
  撤销
</a-button>
<a-button type="primary" class="btn btn-reduction" @click="onReduction">
  还原
</a-button>
// 撤销
onUndo() {},
// 还原
onReduction() {},

然后在data中定义两个辅助变量。

boardData: [],
boardStatus: 0

然后定义一个方法来执行入栈操作(向数组push值)。

setBoardStatus(data) {
  this.boardData.push(data);
  this.boardStatus += 1;
}

然后在 init 方法中对其初始化。

init() {
  // 这里赋值为 -1 是因为下面的入栈操作会对boardStatus进行 +1 处理,这里为了保证一致性
  this.boardStatus = -1;
  this.boardData = [];
  if (this.path !== "") {
    this.filePath = this.path;
  } else {
    // 如果没有默认图片再 push 默认状态
    this.setBoardStatus(
      this.ctx.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
    );
  }
},

然后我们需要在每个改变画板状态的动作执行完后执行入栈操作。

onBrushEnd() {
  this.ctx.closePath();
  this.setBoardStatus(
    this.ctx.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
  );
},
onEraserEnd() {
  this.setBoardStatus(
    this.ctx.getImageData(0, 0, this.canvasWidth, this.canvasHeight)
  );
},

接下来我们开始实现撤销功能,就是将当前的画板置为上一个状态。在 onUndo() 中添加如下代码

onUndo() {
  this.boardStatus--;
  // 判断上一个状态是否存在,存在则将当前画板置为上一个状态,否则提示错误,下标归位。
  if (this.boardStatus >= 0) {
    this.ctx.putImageData(this.boardData[this.boardStatus], 0, 0);
  } else {
    this.boardStatus++;
    this.$message.info("已经是第一步了");
  }
},

还原功能类似与撤销功能,就是将当前的画板置为下一个状态。在 onReduction() 中添加如下代码

onReduction() {
  this.boardStatus++;
  // 判断下一个状态是否存在,存在则将当前画板置为下一个状态,否则提示错误,下标归位。
  if (this.boardStatus < this.boardData.length) {
    this.ctx.putImageData(this.boardData[this.boardStatus], 0, 0);
  } else {
    this.boardStatus--;
    this.$message.info("已经是最新的了");
  }
},

至此撤销还原功能就完全实现了,有一个缺点就是当动作执行的比较多了之后会比较占内存。可以考虑使用 history 实现同样的功能。

源码&在线预览

预览请打开控制台在手机模式下运行。笔画位置有误差请刷新浏览器。
DEMO

源码:GitHub

功能菜单样式优化

看着惨不忍睹的界面是时候优化一下样式了。目前的想法是做一个收缩可移动的菜单。效果如图所示。

效果图1

效果图2

首先说一下实现这个所需要掌握的知识点。包括:touch相关三个方法、transform与transition。(emmmm,好像也没有什么高深的技术。)

菜单控制按钮的实现

这是一个在移动端比较常见的菜单按键。这里自己实现一下。

菜单按钮

首先我们需要在上一章节的基础上更改一下HTML的结构。如下

<template>
  <div ref="box" class="hand-writing">
    <div v-if="!app" style="height: 100%">
      <img v-if="filePath" :src="filePath" alt="" />
      <canvas></canvas>
      <div
        class="menu-box"
        :style="{ top: `${menuTop}px`, left: `${menuLeft}px` }"
        @touchstart="onMenuStart($event)"
        @touchmove="onMenuMove($event)"
        @touchend="onMenuEnd($event)"
      >
        <div :class="{ 'list-show': menuShow }" class="menu-list">
          <div
            :class="{ 'btn-checked': menuShow }"
            class="menu-btn"
            @click="onClickMenuBtn"
          >
            <span class="menu-btn-item"></span>
            <span class="menu-btn-item"></span>
            <span class="menu-btn-item"></span>
          </div>
          <div class="list-item item-checked">1</div>
          <div class="list-item">2</div>
          <div class="list-item">3</div>
          <div class="list-item">4</div>
        </div>
      </div>
    </div>
    <div v-else>暂不支持QQ浏览器,请选择其他浏览器打开。</div>
  </div>
</template>

在canvas同层结构外添加了一层div,是为了将QQ浏览器屏蔽掉。你也可以去掉这一层。然后删除了之前所有的功能按钮,添加了一个菜单控制按钮。菜单按钮的动态style是为了方便控制菜单的位置。然后我们添加及更改一下样式。

.menu-box {
    position: absolute;
    top: 0;
    left: 0;
    z-index: 999;
    width: auto;
    height: auto;
    background: #fff;
}

.menu-box div.list-show {
    height: 100%;
    padding-bottom: 10px;
}

.menu-box .menu-list {
    padding-bottom: 10px;
    width: 35px;
    height: 27px;
    border: 1px solid #000;
    border-radius: 5px;
    text-align: center;
    overflow: hidden;
}

.menu-box .menu-list .menu-btn {
    display: inline-block;
    width: 20px;
    height: 100%;
    line-height: 3px;
}
.menu-box .menu-list .menu-btn .menu-btn-item {
    display: inline-block;
    width: 20px;
    height: 2px;
    background: #666;
    border-radius: 5px;
    transition: transform .5s;
}

.btn-checked .menu-btn-item:first-child {
    transform: translateY(7px) rotate(45deg);
}
.menu-box .menu-list .btn-checked .menu-btn-item:nth-child(2) {
    display: none;
}
.btn-checked .menu-btn-item:last-child {
    transform: rotate(-45deg);
}
.menu-box .menu-list .list-item {
    display: inline-block;
    width: 25px;
    height: 25px;
    border-radius: 5px;
    margin-top: 5px;
}
.item-checked {
    border: 1px solid #000;
}

样式很简单,就不多介绍了,如果你对transform和transition不太了解,可以关注一下我的下一篇博客。

然后我们定义一个控制菜单开关的方法。

onClickMenuBtn() {
  this.menuShow = !this.menuShow;
},

到了这里其实大部分都已经完成了,剩下的只有div的移动,而在前两个章节,我们都在于此打交道,所以是很简单了。我们继续。

菜单移动

在data中添加几个辅助变量。

data() {
  return {
    // 菜单相关
      menuShow: false, // 控制菜单的显示隐藏
      menuTop: 0, // 菜单的top
      menuLeft: 0, // 菜单的left
      menuX: 0, // 辅助坐标X
      menuY: 0 // 辅助坐标Y
  }
}

然后我们添加一下控制移动的三个方法。

onMenuStart(e) {
  // 将点击点存起来
  this.menuX = e.touches[0].clientX;
  this.menuY = e.touches[0].clientY;
},
onMenuMove(e) {
  // 获取当前点击点
  let x = e.touches[0].clientX;
  let y = e.touches[0].clientY;
  // 当前点和在onMenuStart中保存的点的距离就是菜单移动的距离
  this.menuTop += y - this.menuY;
  this.menuLeft += x - this.menuX;
  // 将当前点存起来
  this.menuX = x;
  this.menuY = y;
},
onMenuEnd() {
  //   console.log("object");
},

这时候就可以进行正常的移动的,但是一不小心移出去了怎么办?让我们添加一个边界判断的方法,让菜单只在可视内容区域移动。

// 传入当前的left、top,如果超出边界就处理一下。
handleBorderJudgment(left, top) {
  if (left < 0) {
    left = 0;
  } else if (left > this.canvasWidth - 35) { // 这里减去菜单的宽度为了避免菜单宽度造成的影响
    left = this.canvasWidth - 35;
  }
  if (top < 0) {
    top = 0;
  } else if (top > this.canvasHeight - 27) { // 这里减去菜单的高度为了避免菜单高度造成的影响
    top = this.canvasHeight - 27;
  }
  return {
    left,
    top
  };
},

然后我们只需要在移动过程中使用这个方法就可以了。

onMenuMove(e) {
  let x = e.touches[0].clientX;
  let y = e.touches[0].clientY;
  this.menuTop += y - this.menuY;
  this.menuLeft += x - this.menuX;
  // 将计算出来的值判断一下然后重新赋值
  let _data = this.handleBorderJudgment(this.menuLeft, this.menuTop);
  this.menuTop = _data.top;
  this.menuLeft = _data.left;
  this.menuX = x;
  this.menuY = y;
},

这时候移动到边界会发现还有一个bug,就是打开菜单的时候会撑开内容区域,这不是我们想要的,所以在 onMenuMove 第一行添加一行代码 this.menuShow = false;,当移动时,我们手动让他关闭就可以了。

菜单功能添加

菜单使用的都是icon,但是ant的图标不多,所以我在阿里图标库创建了一个小应用,你可以直接拿去用。

首先,按照ant的介绍,添加如下代码

import { Icon } from 'ant-design-vue';

const IconFont = Icon.createFromIconfontCN({
  scriptUrl: '//at.alicdn.com/t/font_1372570_awwwx5suzr.js',
})
export default {
  components: {
    IconFont,
  }
}

然后更改HTML部分代码如下

<div
  :class="{ 'item-checked': mouseStatus === 'brush' }"
  class="list-item"
>
  <a-popover
    v-model="adjustVisible"
    placement="right"
    :arrowPointAtCenter="true"
    :getPopupContainer="handleGetContainer"
  >
    <template slot="title">
      <div class="adjust-header">
        <span>调节笔画</span>
        <a-icon type="close-circle" @click="adjustVisible = false" />
      </div>
    </template>
    <template slot="content">
      <div class="adjust-content">
        <a-row>
          <a-col :span="8">线条粗细</a-col>
          <a-col :span="16">
            <a-slider :min="1" :max="20" v-model="lineWidth" />
          </a-col>
        </a-row>
        <a-row>
          <a-col :span="8">线条颜色</a-col>
          <a-col :span="16">
            <div
              class="color-body"
              :style="{ background: lineColor }"
              @click="colorVisible = true"
            ></div>
            <div class="picker-box" v-if="colorVisible">
              <a-icon
                class="picker-cancel"
                type="close-circle"
                @click="colorVisible = false"
              />
              <chrome-picker v-model="pickerColor" />
            </div>
          </a-col>
        </a-row>
      </div>
    </template>
    <a-icon type="edit" @click="onClickEdit" />
  </a-popover>
</div>
<div
  :class="{ 'item-checked': mouseStatus === 'eraser' }"
  class="list-item"
>
  <a-popover
    v-model="eraserVisible"
    placement="right"
    :arrowPointAtCenter="true"
    :getPopupContainer="handleGetContainer"
  >
    <template slot="title">
      <div class="adjust-header">
        <span>调节橡皮擦</span>
        <a-icon type="close-circle" @click="eraserVisible = false" />
      </div>
    </template>
    <template slot="content">
      <div class="adjust-content">
        <a-row>
          <a-col :span="8">橡皮宽度</a-col>
          <a-col :span="16">
            <a-slider :min="1" :max="50" v-model="eraserWidth" />
          </a-col>
        </a-row>
      </div>
    </template>
    <icon-font type="icon-eraser" @click="onClickEraser" />
  </a-popover>
</div>
<div class="list-item"><a-icon type="undo" @click="onUndo" /></div>
<div class="list-item">
  <a-icon type="redo" @click="onReduction" />
</div>
<div class="list-item"><a-icon type="delete" @click="onClear" /></div>
<div class="list-item">
  <a-icon type="save" @click="onGenerate" />
</div>

主要功能都是将之前章节的直接拿过来了,无需更多的更改。然后添加两个方法。

// 点击画笔
onClickEdit() {
  this.adjustVisible = true;
  this.mouseStatus = "brush";
},
// 点击橡皮
onClickEraser() {
  this.eraserVisible = true;
  this.mouseStatus = "eraser";
}

至此,整个插件的功能也扩展完成了,样式也改了,其他的小细节,就自己再改一改就好了。

源码&在线预览

预览请打开控制台在手机模式下运行。笔画位置有误差请刷新浏览器。
DEMO

源码:GitHub

参考博文

  1. HTML5 实现橡皮擦的擦除效果: 博客地址
  2. 手把手教你实现一个canvas智绘画板: 博客地址
  3. 使用 CSS overscroll-behavior 控制滚动行为:自定义下拉刷新和溢出效果: 博客地址
  4. Vue Color Pickers for Sketch, Photoshop, Chrome & more: 博客地址
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342