canvas实现截图/图片裁剪功能
前段时间需要实现一个功能:隐式水印溯源
具体功能是:用户导入一张图片,可以提取这张图片的隐式水印信息,显示在页面上。但是后端同事目前只支持对图片中的屏幕内的内容进行溯源,需要用户标记出图片中屏幕的四个顶点,前端将顶点坐标传给服务器,因此需要前端支持用户标记,
开始考虑类似截图的方式将图片中的屏幕截出来,实现了一版后产品经理说用户用手机拍出的照片可能不一定是矩形,如果是只截图出矩形,明显不符合要求,
后来,又实现了一版,让用户使用画线的方式标记出一个四边形,现将两种方案都分享一下
最终方案实现思路:
首先导入一张图片,当鼠标移入到图片上时,把和图片大小一致的canvas显示出来,监听鼠标点击事件,用户每次点击将用户点击的像素计算出来,并将不同的点连接起来,最终将数据传递给服务器。
1.页面html结构
<div class="container">
<header :breadcrumb="breadcrumb" return-icon>
</header>
<div class="content">
<el-form
label-position="top"
:model="form"
content-width="640px"
class="import_form">
<el-form-item
element-loading-text="导入中"
label="导入图片"
prop="imageName">
<el-input v-model="form.imageName" placeholder="请选择图片" readonly>
<el-upload
slot="append"
:action="form.url"
accept=".jpg, .png, .jpeg"
:show-file-list="false"
:before-upload="beforeUpload"
:on-change="onChange"
:on-success="onSuccess"
:on-error="onError">
<el-button icon="h-icon-folder" style="padding: 0 36px" />
</el-upload>
</el-input>
</el-form-item>
</el-form>
<div class="main">
<div class="main_title" :style="{width: uploadSuccess ? imgWidth + 'px' : '1000px'}">
<div>图片<i class="h-icon-info"></i>调整4个锚点,只覆盖视频内容</div>
<el-button type="link" v-show="count !== 0" @click="resetCanvas">重置</el-button>
</div>
<div class="main_content">
<div class="upload_image" ref="uploadImg">
<el-upload
v-show="!uploadSuccess"
drag
:action="form.url"
accept=".jpg, .png, .jpeg"
:show-file-list="false"
:before-upload="beforeUpload"
:on-change="onChange"
:on-success="onSuccess"
:on-error="onError">
<div class="empty_img">
<img src="@/assets/images/upload_img.png">
<div class="empty_text">请上传图片</div>
</div>
</el-upload>
<canvas
v-show="uploadSuccess"
ref="canvasRegion"
class="clip_canvas"
@mousedown="handleDrawStart">
</canvas>
<img v-show="uploadSuccess" ref="img" :src="imgSrc">
</div>
<div class="upload_right">
<el-button
class="trace_btn"
:type="traceType"
:disabled="!uploadSuccess || disabledTrace"
:loading="loadingTrace"
@click="traceMarkContent">
水印内容提取
</el-button>
<div class="water_content">
<div class="water_content_title" v-show="!traceSuccess && !uploadSuccess"><i class="h-icon-info"></i>请先上传图片</div>
<div class="water_content_title" v-show="traceSuccess"><i class="h-icon-enable"></i>提取成功</div>
<el-scrollbar class="water_content_scroll" wrap-class="wrap_scroll">
<ul>
<li
v-for="(con, index) in traceContent"
:key="index"
:title="con">
{{con}}
</li>
</ul>
</el-scrollbar>
<el-button class="copy_btn" type="link" :disabled="this.traceContent.length == 0" @click="copyWaterContent(traceContent)">复制</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
2.js部分
第一步,初始化获取原图与canvas区域尺寸,并将图片按原比例显示
mounted () {
this.calculateSize()
},
// 计算尺寸
calculateSize () {
this.$nextTick(() => {
window.addEventListener('resize', throttle(50, () => {
this.imgWidth = this.$refs.img.clientWidth
this.imgHeight = this.$refs.img.clientHeight
if (this.canvasElement && this.canvasContext()) {
this.getAreaSize()
}
}))
})
},
// 清空画布
resetCanvas () {
this.canvasContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.count = 0
this.trace.points = [
{
pointX: 0,
pointY: 0
},
{
pointX: 1000,
pointY: 0
},
{
pointX: 0,
pointY: 1000
},
{
pointX: 1000,
pointY: 1000
}
]
},
第二步,监视鼠标按下事件,记录点位像素。开始连线
// 鼠标按下
handleDrawStart (e) {
this.count = this.count < 4 ? ++this.count : 1
this.trace.points.splice(this.count - 1, 1, this.getRelativePosition(e))
this.drawRect()
},
// 获取鼠标按下的位置
getRelativePosition (e) {
const clientRect = this.canvasElement.getBoundingClientRect()
// 这里必须取整数
const x = Math.round(e.clientX - clientRect.x)
const y = Math.round(e.clientY - clientRect.y)
return {
pointX: x,
pointY: y
}
},
// 根据开始和结束点来绘制矩形
drawRect () {
// 开始绘制
if (this.count === 1) {
this.canvasContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
this.canvasContext.beginPath()
this.canvasContext.moveTo(this.trace.points[0].pointX, this.trace.points[0].pointY)
} else if (this.count === 2) {
this.canvasContext.lineTo(this.trace.points[1].pointX, this.trace.points[1].pointY)
} else if (this.count === 3) {
this.canvasContext.lineTo(this.trace.points[2].pointX, this.trace.points[2].pointY)
} else if (this.count === 4) {
this.canvasContext.lineTo(this.trace.points[3].pointX, this.trace.points[3].pointY)
this.canvasContext.lineTo(this.trace.points[0].pointX, this.trace.points[0].pointY)
}
this.canvasContext.stroke()
},
// 获取区域宽高
getAreaSize () {
this.canvasWidth = this.imgWidth
this.canvasHeight = this.imgHeight
this.canvasElement.width = this.imgWidth
this.canvasElement.height = this.imgHeight
this.canvasContext.strokeStyle = '#FC5633'
this.canvasContext.lineWidth = 2
},
第三步,将canvas绘制的区域顶点计算出来,传给服务器
// 水印内容提取按钮
traceMarkContent () {
let points
if (this.count !== 0) {
let leftTop
let rightTop
let leftBottom
let rightBottom
const sortPoints = this.quickSort(this.trace.points)
if (sortPoints[0].pointY < sortPoints[1].pointY) {
leftTop = sortPoints[0]
leftBottom = sortPoints[1]
} else {
leftTop = sortPoints[1]
leftBottom = sortPoints[0]
}
if (sortPoints[2].pointY < sortPoints[3].pointY) {
rightTop = sortPoints[2]
rightBottom = sortPoints[3]
} else {
rightTop = sortPoints[3]
rightBottom = sortPoints[2]
}
points = [
leftTop,
rightTop,
leftBottom,
rightBottom
]
points = points.map(p => {
return { pointX: Math.round(p.pointX * 1000 / this.imgWidth), pointY: Math.round(p.pointY * 1000 / this.imgHeight) }
})
} else {
points = [...this.trace.points]
}
const params = {
cacheKey: this.trace.cacheKey,
points
}
this.loadingTrace = true
this.traceContent = []
mark.hideTrace(params).then(({ code, data, msg }) => {
this.loadingTrace = false
if (code === '0') {
this.traceSuccess = true
this.traceContent = data.watermarkContent.split('/n')
}
}).catch(() => {
this.traceSuccess = false
this.loadingTrace = false
})
},
// 排序
quickSort (arr) {
for (let i = 0; i < arr.length - 1; i++) {
for (var j = 0; j < arr.length - i - 1; j++) {
if (arr[j].pointX > arr[j + 1].pointX) {
var oldVal = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = oldVal
}
}
}
return arr
},
// 此处是导入图片与复制溯源结果相关,其中复制是使用了插件vue-clipboard2
// 导入之前
beforeUpload (file) {
const name = file.name.replace(/.+\./, '')
if (name !== 'jpg' && name !== 'jpeg' && name !== 'png') {
this.$message.error('图片格式不正确')
return false
}
return true
},
// 导入改变事件
onChange (file, fileList) {
this.imgSrc = file.url
},
// 导入成功事件
onSuccess ({ data, code, msg }, file) {
if (code === '0') {
setTimeout(() => {
// 获取DOM元素
this.canvasElement = this.$refs.canvasRegion
this.imgEle = this.$refs.img
this.canvasContext = this.canvasElement.getContext('2d')
this.imgWidth = this.imgEle.clientWidth
this.imgHeight = this.imgEle.clientHeight
this.getAreaSize()
})
this.form.imageName = file.name
this.uploadSuccess = true
this.traceSuccess = false
this.traceType = 'primary'
this.trace.cacheKey = data
this.count = 0
this.traceContent = []
} else {
this.form.imageName = ''
this.uploadSuccess = false
this.traceType = 'default'
this.$message.error(msg)
}
},
// 导入失败事件
onError (err, file, fileList) {
this.form.imageName = ''
this.uploadSuccess = false
this.traceType = 'default'
fileList.pop()
this.$message.error(JSON.parse(err.message).error)
},
// 复制功能
copyWaterContent (text) {
const t = text.join(' ')
this.$copyText(t).then(() => {
this.$message.success('复制成功')
}).catch(() => {
this.$message.error('复制失败')
})
}
初版实现思路:
首先导入一张图片,当鼠标移入到图片上时,把和图片大小一致的canvas显示出来,监听鼠标事件,记录裁剪开始点和结束点的位置。然后就可以将截取的四个点传给服务起,也可以在页面显示截图后的图片
1.页面html结构(demo)
<template>
<div class="page">
<div class="operate-btn">
<a-button @click="handleBtnClick">{{screenshot?"就决定是你了":"开始截图"}}</a-button>
</div>
<div class="clip-area">
<canvas
v-show="screenshot"
class="clip-canvas"
@mousedown="handleDrawStart"
@mouseup="handleMouseUp"></canvas>
<img class="big-img" :style="bigImgStyle" src="https://juejin.cn/post/assets/duola.jpg" alt="小图">
<div class="draw-area" :style="drawAreaStyle" v-show="!screenshot"></div>
</div>
<div class="thumb">
<p style="text-align: center">截图预览</p>
<img class="thumb-img" :src="https://juejin.cn/post/clipBase64" v-show="clipBase64" alt="大图">
</div>
</div>
</template>
2.js部分
第一步,初始化获取原图与canvas区域尺寸,并将图片按原比例显示
mounted() {
this.getDomELe()
// 监听图片的加载
this.bigImgEle = document.querySelector(".big-img")
this.bigImgEle.onload = () => {
this.getAreaSize()
this.getImgSize()
this.setImgContain()
}
}
// 获取canvas绘制的尺寸,与外层div大小一致
getAreaSize() {
let areaEle = document.querySelector(".clip-area")
this.canvasElement.width = this.canvasWidth = areaEle.offsetWidth
this.canvasElement.height = this.canvasHeight = areaEle.offsetHeight
this.canvasContext.strokeStyle = "#ff0000"
this.canvasContext.lineWidth = 1
}
// 获取原图展示的尺寸
getImgSize() {
this.imgOriginalWidth = this.bigImgEle.width
this.imgOriginalHeight = this.bigImgEle.height
}
// 将图片尽可能地填满该区域
setImgContain() {
// 宽高比例
let ratio = this.imgOriginalWidth / this.imgOriginalHeight
// 如果图片宽小于高,则以容器高为准,按比例还原宽度
if (this.imgOriginalWidth < this.imgOriginalHeight) {
this.imgDisplayHeight = this.canvasHeight
this.imgDisplayWidth = ratio * this.imgDisplayHeight
this.imgOffset = {
x: (this.canvasWidth - this.imgDisplayWidth) / 2,
y: 0
}
} else {
this.imgDisplayWidth = this.canvasWidth
this.imgDisplayHeight = this.imgDisplayWidth / ratio
this.imgOffset = {
x: 0,
y: (this.canvasHeight - this.imgDisplayHeight) / 2
}
}
}
第二步,用户开始绘制矩形
// 记录在canvas里的开始点-清空画布-监听鼠标移动
handleDrawStart(e) {
this.points.start = this.getRelativePosition(e)
document.addEventListener("mousemove", this.handleDrawMove)
},
getRelativePosition(e) {
let clientRect = this.canvasElement.getBoundingClientRect()
let x = Math.round(e.clientX - clientRect.x)
let y = Math.round(e.clientY - clientRect.y)
return [x, y]
}
// 记录移动的点位
handleDrawMove(e) {
this.points.end = this.getRelativePosition(e)
this.drawRect()
}
// 根据开始和结束点来绘制矩形
drawRect() {
let {
yStart,
yDistance,
xDistance,
xStart,
} = this.formatPointPosition
// 清空画布
this.canvasContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 开始绘制
this.canvasContext.strokeRect(xStart, yStart, xDistance, yDistance)
}
formatPointPosition() {
// 取开始点和结束点的最小值
let [x, y] = this.points.start
let [x1, y1] = this.points.end
let xStart = Math.min(x, x1)
let yStart = Math.min(y, y1)
// 计算移动距离
let xDistance = Math.abs(x - x1)
let yDistance = Math.abs(y - y1)
return {
yDistance,
xDistance,
yStart,
xStart,
}
},
// 鼠标抬起,取消监听鼠标移动事件
handleMouseUp() {
document.removeEventListener("mousemove", this.handleDrawMove)
}
第三步,将canvas绘制的区域顶点计算出来(这里主要显示截取的图片供参考)
handleBtnClick() {
this.screenshot = !this.screenshot
if (!this.screenshot && this.formatPointPosition.xDistance && this.formatPointPosition.yDistance) {
this.exportBase64()
}
}
exportBase64() {
let scale = this.imgDisplayWidth / this.imgOriginalWidth
let canvasElement = document.createElement("canvas")
let canvasContext = canvasElement.getContext("2d")
let startX = (this.formatPointPosition.xStart - this.imgOffset.x) / scale
let startY = (this.formatPointPosition.yStart - this.imgOffset.y) / scale
canvasElement.width = this.formatPointPosition.xDistance / scale
canvasElement.height = this.formatPointPosition.yDistance / scale
canvasContext.drawImage(this.bigImgEle,
startX, startY, canvasElement.width, canvasElement.height,
0, 0, canvasElement.width, canvasElement.height)
this.clipBase64 = canvasElement.toDataURL("image/jpeg")
}
总结:
然最后是实现了隐式水印溯源的功能,但对于前端而言,采用的方案依然是不完美的,如果有更好的方案,欢迎一起探讨。