前言
由于业务需求,需要开发一个可以批改作业的组件,网上搜的一些插件不太符合业务需求,没办法>_<只能自己写呗(此处掉头发两根~)。
其原理是在学生提交的图片上使用画笔批改、橡皮擦、拖拽缩放、旋转、按步骤减分、和其他一些辅助功能操作,期间踩了很多坑,但也是在学习中成长,这里贴出来可以给迷茫的人一个参考,也给自己记录一下。代码写的通俗易懂,我觉得大家只要有点基础都可以看懂,这个案例不是最完美的,但是可以在这个基础上继续完善。
演示
整体的功能演示
保存生成的批改痕迹-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{
100%;
height: 100%;
display: flex;
flex-direction: column;
transform: rotate();
}
.header{
100%;
height: 100px;
flex-shrink: 0;
background-color: #0099CC;
}
.main{
flex: 1;
display: flex;
}
.main .left-wrap{
200px;
background-color: #9999FF;
}
.main .center-wrap{
flex: 1;
height: calc(100vh - 100px);
position: relative;
background-color: #CCFFFF;
overflow: scroll;
}
.main .right-wrap{
300px;
background-color: #CCCCFF;
}
button{
80px;
height: 40px;
}
.step-item.step{
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: {
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}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}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;
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{
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{
60px;
height: 30px;
text-align: center;
line-height: 30px;
color: #fff;
border-radius: 4px;
background-color: aquamarine;
}
.drag-item.chapter{
}
</style>