基於vue和canvas開發的作業批改,包含畫筆、橡皮擦、拖拽到指定位置、保存批改痕跡等功能案例


前言

由於業務需求,需要開發一個可以批改作業的組件,網上搜的一些插件不太符合業務需求,沒辦法>_<只能自己寫唄(此處掉頭發兩根~)。

其原理是在學生提交的圖片上使用畫筆批改、橡皮擦、拖拽縮放、旋轉、按步驟減分、和其他一些輔助功能操作,期間踩了很多坑,但也是在學習中成長,這里貼出來可以給迷茫的人一個參考,也給自己記錄一下。代碼寫的通俗易懂,我覺得大家只要有點基礎都可以看懂,這個案例不是最完美的,但是可以在這個基礎上繼續完善。

演示

整體的功能演示

整體的功能

保存生成的批改痕跡-base64文件預覽(包含拖拽的內容)

保存生成的base64文件預覽

補充

畫筆部分可以用canvas平滑曲線優化,親測效果非常nice

全部代碼

index.vue

<template>
  <div class="correction-wrap">
    <div class="header" />
    <div class="main">
      <div class="left-wrap" />
      <div class="center-wrap">
        <canvas-container ref="canvasContRef" :img-url="imgUrl" />
        <canvas-container ref="canvasContRef" :img-url="imgUrl1" />
      </div>
      <div class="right-wrap">
        <div draggable="true" class="step-item step" @dragstart="dragstart($event, 'step' , 5)">5</div>
        <div draggable="true" class="step-item chapter" @dragstart="dragstart($event, 'chapter' , 5)">
          <img :src="require('./chapter.png')" alt="">
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import CanvasContainer from './canvasContainer'
export default {
  name: 'Index',
  components: {
    CanvasContainer
  },
  data() {
    return {
      imgUrl: 'https://lwlblog.top/images/demo/el-tabs.jpg',
      imgUrl1: 'https://lwlblog.top/images/demo/el-tabs-top.jpg'
    }
  },
  mounted() {

  },
  methods: {
    // 拖拽開始事件 type: 拖拽的對象(step: 按步驟減分,text: 文字批注) value: 值
    dragstart(e, type, value) {
      // e.preventDefault()
      const data = JSON.stringify({ type, value })
      e.dataTransfer.setData('data', data)
    }
  }
}
</script>

<style>
.correction-wrap{
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  transform: rotate();
}
.header{
  width: 100%;
  height: 100px;
  flex-shrink: 0;
  background-color: #0099CC;
}
.main{
  flex: 1;
  display: flex;
}
.main .left-wrap{
  width: 200px;
  background-color: #9999FF;
}
.main .center-wrap{
  flex: 1;
  height: calc(100vh - 100px);
  position: relative;
  background-color: #CCFFFF;
  overflow: scroll;
}
.main .right-wrap{
  width: 300px;
  background-color: #CCCCFF;
}
button{
  width: 80px;
  height: 40px;
}
.step-item.step{
  width: 80px;
  height: 40px;
  text-align: center;
  line-height: 40px;
  border-radius: 4px;
  background-color: #009999;
}
</style>

canvasContainer.vue

<template>
  <div ref="canvasContRef" class="canvas-container">
    <div v-show="imgIsLoad" ref="canvasWrapRef" class="canvas-wrap" :style="canvasStyle">
      <!-- 用於拖拽內容的截圖,不顯示 -->
      <drag-step ref="stepWrapRef" :drag-list="dragList" :drag-style="stepWrapStyle" :is-hide="true" />
      <!-- 用於顯示拖拽內容 -->
      <drag-step :drag-list="dragList" />
      <!-- 畫布 -->
      <canvas ref="canvasRef" @drop="drop" @dragover="dragover" />
    </div>
    <canvas-toolbar @changeTool="changeTool" />
  </div>
</template>

<script>
import CanvasToolbar from './canvasToolbar'
import DragStep from './dragStep'
import domtoimage from 'dom-to-image'
import { mixImg } from 'mix-img'
export default {
  name: 'CanvasContainer',
  components: { CanvasToolbar, DragStep },
  props: {
    imgUrl: {
      type: String,
      require: true,
      default: ''
    }
  },
  data() {
    return {
      canvas: null,
      ctx: null,
      imgIsLoad: false,
      // 所使用的工具名稱 drag draw
      toolName: '',
      // 畫布的屬性值
      canvasValue: {
        width: 0,
        height: 0,
        left: 0,
        top: 0,
        scale: 1,
        rotate: 0,
        cursor: 'default'
      },
      // 拖拽的元素列表
      dragList: [],
      // 記錄當前畫布的操作(暫時沒用)
      imgData: null,
      // 記錄每一步操作
      preDrawAry: []
    }
  },
  computed: {
    canvasStyle() {
      const { width, height, left, top, scale, rotate, cursor } = this.canvasValue
      return {
        width: `${width}px`,
        height: `${height}px`,
        left: `${left}px`,
        top: `${top}px`,
        transform: `rotate(${rotate}deg) scale(${scale})`,
        cursor: cursor
        // backgroundImage: `url(${this.imgUrl})`
      }
    },
    // 上層拖拽樣式(用於dom截圖)
    stepWrapStyle() {
      const { width, height } = this.canvasValue
      return {
        width: `${width}px`,
        height: `${height}px`
      }
    }
  },
  mounted() {
    const canvas = this.$refs.canvasRef
    const ctx = canvas.getContext('2d')

    this.loadImg(canvas, ctx)
    this.changeTool('drag')
    // 監聽窗口發生變化
    window.addEventListener('resize', this.reload)
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.reload)
  },
  methods: {
    // 監聽窗口發生變化
    reload() {
      this.$nextTick(() => {
        const canvas = this.$refs.canvasRef
        this.canvasCenter(canvas)
      })
    },

    // 加載圖片
    loadImg(canvas, ctx) {
      const img = new Image()
      // 圖片加載成功
      img.onload = () => {
        console.log('圖片加載成功')
        this.imgIsLoad = true
        canvas.width = img.width
        canvas.height = img.height
        this.$set(this.canvasValue, 'width', img.width)
        this.$set(this.canvasValue, 'height', img.height)

        canvas.style.backgroundImage = `url(${this.imgUrl})`
        this.canvasCenter(canvas)

        // this.loadHistory(ctx)
      }
      // 圖片加載失敗
      img.onerror = () => {
        console.log('圖片加載失敗!')
      }
      img.src = this.imgUrl
    },

    // 加載歷史畫筆記錄 img是保存的base64格式的畫筆軌跡圖
    loadHistory(ctx, img) {
      const imgCatch = new Image()
      imgCatch.src = img
      imgCatch.onload = () => {
        ctx.drawImage(imgCatch, 0, 0, imgCatch.width, imgCatch.height)
      }
    },

    // 切換工具
    changeTool(name) {
      console.log(name)
      // 清除拖拽的按下事件
      const wrapRef = this.$refs.canvasWrapRef
      wrapRef.onmousedown = null
      const canvas = this.$refs.canvasRef
      const ctx = canvas.getContext('2d')
      switch (name) {
        case 'drag':
          this.dragCanvas(canvas)
          break
        case 'draw':
          this.drawPaint(canvas, ctx)
          break
        case 'eraser':
          this.eraser(canvas, ctx)
          break
        case 'revoke':
          this.revoke(canvas, ctx)
          break
        case 'clear':
          this.clearCanvas(canvas, ctx)
          break
        case 'save':
          this.saveCanvas(canvas)
          break
        case 'rotate':
          this.$set(this.canvasValue, 'rotate', this.canvasValue.rotate + 90)
          break
        case 'enlarge':
          this.$set(this.canvasValue, 'scale', this.canvasValue.scale + 0.2)
          break
        case 'narrow':
          this.$set(this.canvasValue, 'scale', this.canvasValue.scale - 0.2)
          break
        default:
          break
      }
    },

    // 拖拽畫布
    dragCanvas(canvas) {
      console.log('dragCanvas')
      // 清除上次監聽的事件
      const wrapRef = this.$refs.canvasWrapRef
      const container = this.getPosition(this.$refs.canvasContRef)
      let isDown = false

      wrapRef.onmousedown = (e) => {
        isDown = true
        this.$set(this.canvasValue, 'cursor', 'move')
        // 算出鼠標相對元素的位置
        const disX = e.clientX - wrapRef.offsetLeft
        const disY = e.clientY - wrapRef.offsetTop

        document.onmousemove = (e) => {
          if (!isDown) return
          // 用鼠標的位置減去鼠標相對元素的位置,得到元素的位置
          let left = e.clientX - disX
          let top = e.clientY - disY

          // 判斷canvas是否在顯示范圍內,減4是border=2px的原因
          const width = container.width - canvas.width / 2 - 4
          const height = container.height - canvas.height / 2 - 4
          left = Math.min(Math.max(-canvas.width / 2, left), width)
          top = Math.min(Math.max(-canvas.height / 2, top), height)

          this.$set(this.canvasValue, 'left', left)
          this.$set(this.canvasValue, 'top', top)
        }

        document.onmouseup = (e) => {
          isDown = false
          document.onmousemove = null
          this.$set(this.canvasValue, 'cursor', 'default')
        }
      }
    },

    // 畫筆
    drawPaint(canvas, ctx) {
      // const wrapRef = this.$refs.canvasWrapRef
      canvas.onmousedown = (e) => {
        this.$set(this.canvasValue, 'cursor', 'crosshair')
        this.imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
        this.preDrawAry.push(this.imgData)
        ctx.beginPath()
        ctx.lineWidth = 2
        ctx.strokeStyle = 'red'
        ctx.moveTo(e.offsetX, e.offsetY)

        canvas.onmousemove = (e) => {
          ctx.lineTo(e.offsetX, e.offsetY)
          ctx.stroke()
        }
      }

      // 鼠標抬起取消鼠標移動的事件
      document.onmouseup = (e) => {
        canvas.onmousemove = null
        ctx.closePath()
        this.$set(this.canvasValue, 'cursor', 'default')
      }

      // 鼠標移出畫布時 移動事件取消
      // document.onmouseout = (e) => {
      //   document.onmousemove = null
      //   ctx.closePath()
      // }
    },

    // 橡皮擦
    eraser(canvas, ctx, r = 10) {
      // const wrapRef = this.$refs.canvasWrapRef
      canvas.onmousedown = (e) => {
        this.imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
        this.preDrawAry.push(this.imgData)
        let x1 = e.offsetX
        let y1 = e.offsetY

        // 鼠標第一次點下的時候擦除一個圓形區域,同時記錄第一個坐標點
        ctx.save()
        ctx.beginPath()
        ctx.arc(x1, y1, r, 0, 2 * Math.PI)
        ctx.clip()
        ctx.clearRect(0, 0, canvas.width, canvas.height)
        ctx.restore()

        canvas.onmousemove = (e) => {
          const x2 = e.offsetX
          const y2 = e.offsetY
          // 獲取兩個點之間的剪輯區域四個端點
          const asin = r * Math.sin(Math.atan((y2 - y1) / (x2 - x1)))
          const acos = r * Math.cos(Math.atan((y2 - y1) / (x2 - x1)))

          // 保證線條的連貫,所以在矩形一端畫圓
          ctx.save()
          ctx.beginPath()
          ctx.arc(x2, y2, r, 0, 2 * Math.PI)
          ctx.clip()
          ctx.clearRect(0, 0, canvas.width, canvas.height)
          ctx.restore()

          // 清除矩形剪輯區域里的像素
          ctx.save()
          ctx.beginPath()
          ctx.moveTo(x1 + asin, y1 - acos)
          ctx.lineTo(x2 + asin, y2 - acos)
          ctx.lineTo(x2 - asin, y2 + acos)
          ctx.lineTo(x1 - asin, y1 + acos)
          ctx.closePath()
          ctx.clip()
          ctx.clearRect(0, 0, canvas.width, canvas.height)
          ctx.restore()

          // 記錄最后坐標
          x1 = x2
          y1 = y2
        }
      }

      // 鼠標抬起取消鼠標移動的事件
      document.onmouseup = (e) => {
        canvas.onmousemove = null
      }

      // 鼠標移出畫布時 移動事件取消
      // canvas.onmouseout = (e) => {
      //   canvas.onmousemove = null
      // }
    },

    // 撤銷
    revoke(canvas, ctx) {
      if (this.preDrawAry.length > 0) {
        const popData = this.preDrawAry.pop()
        ctx.putImageData(popData, 0, 0)
      } else {
        this.clearCanvas(canvas, ctx)
      }
    },

    // 清空畫布
    clearCanvas(canvas, ctx) {
      ctx.clearRect(0, 0, canvas.width, canvas.height)
    },

    // 保存
    saveCanvas(canvas) {
      const wrapRef = this.$refs.stepWrapRef.$el
      const { width, height } = this.canvasValue
      const image = canvas.toDataURL('image/png')
      console.log(this.preDrawAry)
      domtoimage.toPng(wrapRef)
        .then((dataUrl) => {
          console.log(dataUrl)
          const mixConfig = {
            'base': {
              'backgroundImg': image,
              'width': width,
              'height': height,
              'quality': 0.8,
              'fileType': 'png'
            },
            'dynamic': [{
              'type': 1,
              'size': {
                'dWidth': width,
                'dHeight': height
              },
              'position': {
                'x': 0,
                'y': 0
              },
              'imgUrl': dataUrl
            }]
          }
          mixImg(mixConfig).then(res => {
            console.log(res.data.base64)
          })
        })
        .catch((error) => {
          console.error('oops, something went wrong!', error)
        })
    },

    // 獲取dom元素在頁面中的位置與大小
    getPosition(target) {
      const width = target.offsetWidth
      const height = target.offsetHeight
      let left = 0
      let top = 0
      do {
        left += target.offsetLeft || 0
        top += target.offsetTop || 0
        target = target.offsetParent
      } while (target)
      return { width, height, left, top }
    },

    // canvas居中顯示
    canvasCenter(canvas) {
      const wrap = this.getPosition(this.$refs.canvasContRef)
      const left = (wrap.width - canvas.width) / 2
      const top = (wrap.height - canvas.height) / 2
      this.$set(this.canvasValue, 'left', left)
      this.$set(this.canvasValue, 'top', top)
    },

    drop(e) {
      // e.preventDefault()
      const { type, value } = JSON.parse(e.dataTransfer.getData('data'))
      console.log(e.offsetX, e.offsetY)
      this.dragList.push({
        x: e.offsetX,
        y: e.offsetY,
        type,
        value
      })
    },

    dragover(e) {
      // 取消默認動作是為了drop事件可以觸發
      e.preventDefault()
      // console.log(e)
    }
  }
}
</script>

<style scoped>
.canvas-container{
  position: relative;
  width: 100%;
  height: 400px;
  border: 2px solid #f0f;
  background-color: lightblue;
  box-sizing: border-box;
  overflow: hidden;
}
.canvas-container .canvas-wrap{
  position: absolute;
  transition: transform .3s;
  /* background-color: #ff0; */
}
.canvas-toolbar{
  width: 720px;
  height: 40px;
  position: absolute;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  background-color: rgba(0, 0, 0, .3);
  user-select: none;
}
</style>

canvasToolbar.vue

<template>
  <div class="canvas-toolbar">
    <button v-for="tool in tools" :key="tool.name" @click="changeTool(tool.code)">{{ tool.name }}</button>
  </div>
</template>

<script>
export default {
  props: {

  },
  data() {
    return {
      tools: [{
        code: 'drag',
        name: '拖動'
      }, {
        code: 'draw',
        name: '畫筆'
      }, {
        code: 'eraser',
        name: '橡皮'
      }, {
        code: 'revoke',
        name: '撤銷'
      }, {
        code: 'clear',
        name: '重置'
      }, {
        code: 'save',
        name: '保存'
      }, {
        code: 'rotate',
        name: '旋轉'
      }, {
        code: 'enlarge',
        name: '放大'
      }, {
        code: 'narrow',
        name: '縮小'
      }]
    }
  },
  methods: {
    changeTool(name, value) {
      this.$emit('changeTool', name)
    },

    changeScale() {

    }
  }
}
</script>

<style>

</style>

dragStep.vue

<template>
  <div class="drag-step" :class="{'hide': isHide}" :style="dragStyle">
    <div
      v-for="(step, index) in dragList"
      :key="index"
      class="drag-item"
      :class="step.type"
      :style="{
        left: step.x - 30 + 'px',
        top: step.y - 15 + 'px'
      }"

      @click="clickStepItem(step.value)"
    >
      <span v-if="step.type === 'step'">{{ step.value }}</span>
      <img v-if="step.type === 'chapter'" draggable="false" :src="require('./chapter.png')" alt="">
    </div>
  </div>
</template>

<script>
export default {
  props: {
    // 是否隱藏在下方(用於domtoimg截圖)
    isHide: {
      type: Boolean,
      default: false
    },
    // 拖拽的元素列表
    dragList: {
      type: Array,
      default: () => []
    },
    // 應該與 isHide=true 時使用
    dragStyle: {
      type: Object,
      default: () => ({})
    }
  },
  methods: {
    clickStepItem(value) {
      console.log(value)
    }
  }
}
</script>

<style scoped>
.drag-step.hide{
  position: absolute;
  top: 0;
  left: 0;
  z-index: -1;
}
.drag-item{
  position: absolute;
  user-select: none;
}
.drag-item.step{
  width: 60px;
  height: 30px;
  text-align: center;
  line-height: 30px;
  color: #fff;
  border-radius: 4px;
  background-color: aquamarine;
}
.drag-item.chapter{

}
</style>


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM