【画了个画】给你一个画板,你会把女朋友画成啥样子呢?

1,308 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情

前言

最近看到女朋友公司有很多电子文档需要签字,发现都是用的canvas实现的,那就有了一个想法,可不可以弄个canvas画板,不仅可以写字,还能画画呢?

看看我画的丁老头吧

image.png

额。。。貌似。。好像。。可能。。不太好看哦。。😂😂😂

技术说明

👉 文章中的代码是用的uniapp,当然你可以改成你自己的HTML或者小程序写法,因为canvas技术都是一样的,只是包装起来的框架不一样而已。

先来做个画布吧

👉 页面代码

<view class="tn-sign-board__content">
  <view class="tn-sign-board__content__wrapper">
    <canvas class="tn-sign-board__content__canvas" :canvas-id="canvasName" :disableScroll="true" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd"></canvas>
  </view>
</view>
  • 👆 canvasId需要双向数据绑定的哦,为了后面方法中直接获取

  • 👆 由于canvas限制大小,需要使用 disableScroll 将画布设置为禁止滚动

  • 👆 在canvas中设置了三个触摸事件touchstarttouchmovetouchend

👉 页面上除了需要画布之外,还需要不同颜色的画笔哦

<view class="tn-sign-board__tools__color">
    <view
      v-for="(item, index) in signSelectColor"
      :key="index"
      class="tn-sign-board__tools__color__item"
      :class="[{'tn-sign-board__tools__color__item--active': currentSelectColor === item}]"
      :style="{backgroundColor: item}"
      @tap="colorSwitch(item)"
    ></view>
</view>
  • signSelectColor 用的是一个数组哦,数组里面就是配置的所有颜色
  • currentSelectColor 用来匹配画笔颜色的哦,默认就是第一个颜色了
data(){
    return {
        signSelectColor: ['#999888', '#E83A30', '#E73983', '#F88011', '#DDD912', '#01BEFF'],
        currentSelectColor: '#999888'
    }
}

image.png

写画布和画笔逻辑

👉 定义所有相关字段

data() {
  return {
    canvasName: 'tn-sign-canvas',
    ctx: null,
    canvasWidth: 300,
    canvasHeight: 800,
    // 第一次触摸
    firstTouch: false,
    // 透明度
    transparent: 1,
    // 笔迹倍数
    lineSize: 1.5,
    // 最小画笔半径
    minLine: 0.5,
    // 最大画笔半径
    maxLine: 4,
    // 画笔压力
    pressure: 1,
    // 顺滑度,用60的距离来计算速度
    smoothness: 60,
    // 当前触摸的点
    currentPoint: {},
    // 当前线条
    currentLine: [],
    // 画笔圆半径
    radius: 1,
    // 裁剪区域
    cutArea: {
      top: 0,
      right: 0,
      bottom: 0,
      left: 0
    },
    // 上一个点
    lastPoint: 0,
    // 笔迹
    chirography: [],
    // 画线轨迹,生成线条的实际点
    linePrack: []
  }
}

👍 在created生命周期函数中创建canvas画布

created() {
  // 创建canvas
  this.ctx = uni.createCanvasContext(this.canvasName, this)
  // 初始化Canvas
  this.$nextTick(() => {
    this.initCanvas('#FFFFFF')
  })
}

👍 在methods中创建初始化的方法

methods: {
    // 初始化Canvas
      initCanvas(color) {
        /* 将canvas背景设置为 白底,不设置  导出的canvas的背景为透明 */
        // rect() 参数说明  矩形路径左上角的横坐标,左上角的纵坐标, 矩形路径的宽度, 矩形路径的高度
        // 矩形的宽高需要减去边框的宽度
        this.ctx.rect(0, 0, this.canvasWidth - uni.upx2px(4), this.canvasHeight - uni.upx2px(4))
        this.ctx.setFillStyle(color)
        this.ctx.fill()
        this.ctx.draw()
      },
}

👍 在canvas中定义了好几个画布的方法也需要在methods中创建哦

// 开始画
  onTouchStart(e) {
    if (e.type != 'touchstart') return false

    // 设置线条颜色
    this.ctx.setFillStyle(this.currentSelectColor)
    // 设置透明度
    this.ctx.setGlobalAlpha(this.transparent)
    let currentPoint = {
      x: e.touches[0].x,
      y: e.touches[0].y
    }
    let currentLine = this.currentLine
    currentLine.unshift({
      time: new Date().getTime(),
      dis: 0,
      x: currentPoint.x,
      y: currentPoint.y
    })
    this.currentPoint = currentPoint

    if (this.firstTouch) {
      this.cutArea = {
        top: currentPoint.y,
        right: currentPoint.x,
        bottom: currentPoint.y,
        left: currentPoint.x
      }
      this.firstTouch = false
    }

    this.pointToLine(currentLine)
  },
  // 正在画
  onTouchMove(e) {
    if (e.type != 'touchmove') return false
    if (e.cancelable) {
      // 判断默认行为是否已经被禁用
      if (!e.defaultPrevented) {
        e.preventDefault()
      }
    }
    let point = {
      x: e.touches[0].x,
      y: e.touches[0].y
    }

    if (point.y < this.cutArea.top) {
      this.cutArea.top = point.y
    }
    if (point.y < 0) this.cutArea.top = 0

    if (point.x < this.cutArea.right) {
      this.cutArea.right = point.x
    }
    if (this.canvasWidth - point.x <= 0) {
      this.cutArea.right = this.canvasWidth
    }
    if (point.y > this.cutArea.bottom) {
      this.cutArea.bottom = this.canvasHeight
    }
    if (this.canvasHeight - point.y <= 0) {
      this.cutArea.bottom = this.canvasHeight
    }
    if (point.x < this.cutArea.left) {
      this.cutArea.left = point.x
    }
    if (point.x < 0) this.cutArea.left = 0

    this.lastPoint = this.currentPoint
    this.currentPoint = point

    let currentLine = this.currentLine
    currentLine.unshift({
      time: new Date().getTime(),
      dis: this.distance(this.currentPoint, this.lastPoint),
      x: point.x,
      y: point.y
    })

    this.pointToLine(currentLine)
  },
  // 移动结束
  onTouchEnd(e) {
    if (e.type != 'touchend') return false
    let point = {
      x: e.changedTouches[0].x,
      y: e.changedTouches[0].y
    }
    this.lastPoint = this.currentPoint
    this.currentPoint = point

    let currentLine = this.currentLine
    currentLine.unshift({
      time: new Date().getTime(),
      dis: this.distance(this.currentPoint, this.lastPoint),
      x: point.x,
      y: point.y
    })

    //一笔结束,保存笔迹的坐标点,清空,当前笔迹
    //增加判断是否在手写区域
    this.pointToLine(currentLine)
    let currentChirography = {
      lineSize: this.lineSize,
      lineColor: this.currentSelectColor
    }

    let chirography = this.chirography
    chirography.unshift(currentChirography)
    this.chirography = chirography

    let linePrack = this.linePrack
    linePrack.unshift(this.currentLine)
    this.linePrack = linePrack
    this.currentLine = []
  }

👍 画笔中还有切换的事件哦

// 切换画笔颜色
colorSwitch(color) {
    this.currentSelectColor = color
}

👍 绘制两点之间的线条时,还需要计算中间的插值,让线条变得更加顺滑

// 绘制两点之间的线条
pointToLine(line) {
    this.calcBethelLine(line)
},
// 计算插值,让线条更加圆滑
calcBethelLine(line) {
    if (line.length <= 1) {
      line[0].r = this.radius
      return
    }
    let x0,
      x1,
      x2,
      y0,
      y1,
      y2,
      r0,
      r1,
      r2,
      len,
      lastRadius,
      dis = 0,
      time = 0,
      curveValue = 0.5;
    if (line.length <= 2) {
      x0 = line[1].x
      y0 = line[1].y
      x2 = line[1].x + (line[0].x - line[1].x) * curveValue
      y2 = line[1].y + (line[0].y - line[1].y) * curveValue
      x1 = x0 + (x2 - x0) * curveValue
      y1 = y0 + (y2 - y0) * curveValue
    } else {
      x0 = line[2].x + (line[1].x - line[2].x) * curveValue
      y0 = line[2].y + (line[1].y - line[2].y) * curveValue
      x1 = line[1].x
      y1 = line[1].y
      x2 = x1 + (line[0].x - x1) * curveValue
      y2 = y1 + (line[0].y - y1) * curveValue
    }
    // 三个点分别是(x0,y0),(x1,y1),(x2,y2) ;(x1,y1)这个是控制点,控制点不会落在曲线上;实际上,这个点还会手写获取的实际点,却落在曲线上
    len = this.distance({
      x: x2,
      y: y2
    }, {
      x: x0,
      y: y0
    })
    lastRadius = this.radius
    for (let i = 0; i < line.length - 1; i++) {
      dis += line[i].dis
      time += line[i].time - line[i + 1].time
      if (dis > this.smoothness) break
    }

    this.radius = Math.min((time / len) * this.pressure + this.minLine, this.maxLine) * this.lineSize
    line[0].r = this.radius
    // 计算笔迹半径
    if (line.length <= 2) {
      r0 = (lastRadius + this.radius) / 2
      r1 = r0
      r2 = r1
    } else {
      r0 = (line[2].r + line[1].r) / 2
      r1 = line[1].r
      r2 = (line[1].r + line[0].r) / 2
    }
    let n = 5
    let point = []
    for (let i = 0; i < n; i++) {
      let t = i / (n - 1)
      let x = (1 - t) * (1 - t) * x0 + 2 * t * (1 - t) * x1 + t * t * x2
      let y = (1 - t) * (1 - t) * y0 + 2 * t * (1 - t) * y1 + t * t * y2
      let r = lastRadius + ((this.radius - lastRadius) / n) * i
      point.push({
        x,
        y,
        r
      })
      if (point.length === 3) {
        let a = this.ctaCalc(point[0].x, point[0].y, point[0].r, point[1].x, point[1].y, point[1].r, point[2].x, point[2].y, point[2].r)
        a[0].color = this.currentSelectColor

        this.drawBethel(a, true)
        point = [{
          x,
          y,
          r
        }]
      }
    }
    this.currentLine = line
}

👍 画了线条就得计算两点之间的距离

// 求两点之间的距离
  distance(a, b) {
    let x = b.x - a.x
    let y = b.y - a.y
    return Math.sqrt(x * x + y * y)
  },
  // 计算点信息
  ctaCalc(x0, y0, r0, x1, y1, r1, x2, y2, r2) {
    let a = [],
      vx01,
      vy01,
      norm,
      n_x0,
      n_y0,
      vx21,
      vy21,
      n_x2,
      n_y2;
    vx01 = x1 - x0
    vy01 = y1 - y0
    norm = Math.sqrt(vx01 * vx01 + vy01 * vy01 + 0.0001) * 2
    vx01 = (vx01 / norm) * r0
    vy01 = (vy01 / norm) * r0
    n_x0 = vy01
    n_y0 = -vx01
    vx21 = x1 - x2
    vy21 = y1 - y2
    norm = Math.sqrt(vx21 * vx21 + vy21 * vy21 + 0.0001) * 2
    vx21 = (vx21 / norm) * r2
    vy21 = (vy21 / norm) * r2
    n_x2 = -vy21
    n_y2 = vx21
    a.push({
      mx: x0 + n_x0,
      my: y0 + n_y0,
      color: '#080808'
    })
    a.push({
      c1x: x1 + n_x0,
      c1y: y1 + n_y0,
      c2x: x1 + n_x2,
      c2y: y1 + n_y2,
      ex: x2 + n_x2,
      ey: y2 + n_y2
    })
    a.push({
      c1x: x2 + n_x2 - vx21,
      c1y: y2 + n_y2 - vy21,
      c2x: x2 - n_x2 - vx21,
      c2y: y2 - n_y2 - vy21,
      ex: x2 - n_x2,
      ey: y2 - n_y2
    })
    a.push({
      c1x: x1 - n_x2,
      c1y: y1 - n_y2,
      c2x: x1 - n_x0,
      c2y: y1 - n_y0,
      ex: x0 - n_x0,
      ey: y0 - n_y0
    })
    a.push({
      c1x: x0 - n_x0 - vx01,
      c1y: y0 - n_y0 - vy01,
      c2x: x0 + n_x0 - vx01,
      c2y: y0 + n_y0 - vy01,
      ex: x0 + n_x0,
      ey: y0 + n_y0
    })
    a[0].mx = a[0].mx.toFixed(1)
    a[0].mx = parseFloat(a[0].mx)
    a[0].my = a[0].my.toFixed(1)
    a[0].my = parseFloat(a[0].my)
    for (let i = 1; i < a.length; i++) {
      a[i].c1x = a[i].c1x.toFixed(1)
      a[i].c1x = parseFloat(a[i].c1x)
      a[i].c1y = a[i].c1y.toFixed(1)
      a[i].c1y = parseFloat(a[i].c1y)
      a[i].c2x = a[i].c2x.toFixed(1)
      a[i].c2x = parseFloat(a[i].c2x)
      a[i].c2y = a[i].c2y.toFixed(1)
      a[i].c2y = parseFloat(a[i].c2y)
      a[i].ex = a[i].ex.toFixed(1)
      a[i].ex = parseFloat(a[i].ex)
      a[i].ey = a[i].ey.toFixed(1)
      a[i].ey = parseFloat(a[i].ey)
    }
    return a
  }

👍 最关键的一步当然是绘制贝塞尔曲线

// 绘制贝塞尔曲线
  drawBethel(point, is_fill, color) {
    this.ctx.beginPath()
    this.ctx.moveTo(point[0].mx, point[0].my)
    if (color != undefined) {
      this.ctx.setFillStyle(color)
      this.ctx.setStrokeStyle(color)
    } else {
      this.ctx.setFillStyle(point[0].color)
      this.ctx.setStrokeStyle(point[0].color)
    }
    for (let i = 1; i < point.length; i++) {
      this.ctx.bezierCurveTo(point[i].c1x, point[i].c1y, point[i].c2x, point[i].c2y, point[i].ex, point[i].ey)
    }
    this.ctx.stroke()
    if (is_fill != undefined) {
      //填充图形 ( 后绘制的图形会覆盖前面的图形, 绘制时注意先后顺序 )
      this.ctx.fill()
    }
    this.ctx.draw(true)
  }

写个字吧

Kapture 2022-10-01 at 16.53.17.gif

可以画画之后,就需要变成图片保存下来咯

👉 在页面上放几个按钮吧

<view class="tn-sign-board__tools__button">
    <view class="tn-sign-board__tools__button__item tn-bg-red" @tap="reDraw">清除</view>
    <view class="tn-sign-board__tools__button__item tn-bg-blue" @tap="save">保存</view>
    <view class="tn-sign-board__tools__button__item tn-bg-indigo" @tap="previewImage">预览</view>
</view>

image.png

👉 清空画布事件,直接调用初始化画布方法就可以

// 重置绘画板
  reDraw() {
    this.initCanvas('#FFFFFF')
  }

👉 保存到本地事件

// 保存
  save() {
    // 在组件内使用需要第二个参数this
    uni.canvasToTempFilePath({
      canvasId: this.canvasName,
      fileType: 'png',
      quality: 1,
      success: (res) => {
        if (this.rotate) {
          this.getRotateImage(res.tempFilePath).then((res) => {
            this.$emit('save', res)
          }).catch(err => {
            this.$t.message.toast('旋转图片失败')
          })
        } else {
          this.$emit('save', res.tempFilePath)
        }
      },
      fail: () => {
        this.$t.message.toast('保存失败')
      }
    }, this)
  }

👉 预览图片事件

// 预览图片
  previewImage() {
    // 在组件内使用需要第二个参数this
    uni.canvasToTempFilePath({
      canvasId: this.canvasName,
      fileType: 'png',
      quality: 1,
      success: (res) => {
        if (this.rotate) {
          this.getRotateImage(res.tempFilePath).then((res) => {
            uni.previewImage({
              urls: [res]
            })
          }).catch(err => {
            this.$t.message.toast('旋转图片失败')
          })
        } else {
          uni.previewImage({
            urls: [res.tempFilePath]
          })
        }
      },
      fail: (e) => {
        this.$t.message.toast('预览失败')
      }
    }, this)
  }

👉 预览保存时,可选旋转图片

// 旋转图片
  async getRotateImage(dataUrl) {
    const url = dataUrl

    // 创建新画布
    const tempCtx = uni.createCanvasContext('temp-tn-sign-canvas', this)
    const width = this.canvasWidth
    const height = this.canvasHeight
    tempCtx.restore()
    tempCtx.save()
    tempCtx.translate(0, height)
    tempCtx.rotate(270 * Math.PI / 180)
    tempCtx.drawImage(url, 0, 0, width, height)
    tempCtx.draw()
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        uni.canvasToTempFilePath({
          canvasId: 'temp-tn-sign-canvas',
          fileType: 'png',
          x: 0,
          y: height - width,
          width: height,
          height: width,
          success: res => resolve(res.tempFilePath),
          fail: reject
        }, this)
      }, 50)
    })
  }

页面样式(只有部分样式,其他的可以根据实际需求修改)

<style lang="scss">
  .tn-sign-board {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    height: 100%;
    background-color: #E6E6E6;
    z-index: 997;
    display: flex;
    flex-direction: row-reverse;
    
    &__content {
      width: 84%;
      height: 100%;
      
      &__wrapper {
        width: calc(100% - 60rpx);
        height: calc(100% - 60rpx);
        margin: 30rpx;
        border-radius: 20rpx;
        border: 2rpx dotted #AAAAAA;
        overflow: hidden;
      }
      
      &__canvas {
        width: 100%;
        height: 100%;
        background-color: #FFFFFF;
      }
    }
    
    &__tools {
      width: 16%;
      height: 100%;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: space-between;
      
      &__color {
        margin-top: 30rpx;
        
        &__item {
          width: 70rpx;
          height: 70rpx;
          border-radius: 100rpx;
          margin: 20rpx auto;
          
          &--active {
            position: relative;
            
            &::after {
              content: '';
              position: absolute;
              top: 50%;
              left: 50%;
              width: 40%;
              height: 40%;
              border-radius: 100rpx;
              background-color: #FFFFFF;
              transform: translate(-50%, -50%);
            }
          }
        }
      }
      
      &__button {
        margin-bottom: 30rpx;
        display: flex;
        flex-direction: column;
        
        &__item {
          width: 130rpx;
          height: 60rpx;
          line-height: 60rpx;
          text-align: center;
          margin: 60rpx auto;
          border-radius: 10rpx;
          color: #FFFFFF;
          transform-origin: center center;
          transform: rotateZ(90deg);
        }
      }
    }
  }
</style>

总结

还不快去给你女朋友画一张画像吧,看她会不会打死你~~~😂😂😂