前言
今天和大家分享一下我个人使用canvas实现前端图片裁剪功能的方法,canvas算是前端进阶的一个必修课了,不太了解的同学可以先去找点资料打点基础,个人对canvas的理解就是把他类比成一个画图工具,在画图工具中我们是使用可视化的界面去画图,而在canvas里则是使用代码去实现我们在可视化界面的操作进而去画图
基础实现
基础的布局和功能就不说了,相信各位都会,无非就是图片上面加一层蒙版,细节会在最后面给大家总结。图片的生成可以直接用img标签或者canvas里的drawImage方法,我这里使用的是canvas
img.src = props.imgSrc
scale = props.width / img.width
img.onload = function () {
ctx?.drawImage(img, 0, 0, props.width * r, scale * img.height * r)
}
img是img标签的dom。
后两个0是生成的图片在canvas画布里的坐标,(0,0)表示左上的端点
scale是原图的宽度和你所需图片的宽度比例,在这里用于转换高度,不然你的图会被拉伸
r变量是倍率用于让canvas变清晰的,会在最后面总结
drawImage的详细api说明:https://www.w3school.com.cn/tags/canvas_drawimage.asp
后续裁剪的实现也是使用到这个drawImage方法,不熟悉的同学可以先熟悉一下
选择框的实现
先上效果图,我们需要在蒙版挖出来一个口,让底层的图片清楚的显示出来
一开始想的方法是通过设置背景颜色来达到这一效果,但发现不行,因为我的蒙版只是简单的覆盖在上面,并设置背景颜色而已,想要这样‘简单’地实现就要不简单地对蒙版进行处理,这里欢迎各位大佬想个好方法
既然挖去不行,那就可以换个思路,用裁剪的图放入选择框里面
<div v-show="showBorder" class="select-content"></div> // 蒙板
<img v-show="isShowSelect" :src="imgSrc" class="select-img" //覆盖在蒙板上的图
:style="{ width: width + 'px', 'clip-path': clipPath }" draggable="false" />
图片的裁剪也是有两种办法:
使用上述的drawImage方法
使用css里面的clip-path属性
我这里使用的是clip-path,文档:https://developer.mozilla.org/zh-CN/docs/Web/CSS/clip-path#%E6%B5%8F%E8%A7%88%E5%99%A8%E5%85%BC%E5%AE%B9%E6%80%A7,菜鸟和w3c里的文档对这个属性的记录并不是很详细,至少我们需要的polygon这个属性没有
先来讲一下polygon:
clip-path: polygon(134px 161px, 268px 161px, 268px 236px, 134px 236px);
polygon里面有四个值,分别对应左上、右上、右下、左下四个点的坐标,将图片裁剪成四个点内的内容,值得注意的是他不会改变img元素的大小,只是保留下裁剪的内容
原图
裁剪后的图,可以看到元素大小是不会变的
根据这一特性我们可以在蒙板上添加一个和图片大小一样的图片,然后对这个最上面的图片使用clip-path进行裁剪得到裁剪的内容,附上完整的dom
<div class="wind-cut" ref="windCut"
:style="{ width: width + 'px', height: height + 'px' }"
@mousedown="startSelect" @mousemove="moveSelect" @mouseup="endSelect">
<canvas id="wind-cut-canvas" :width="width * ratio" :height="height * ratio"
:style="{ width: width + 'px', height: height + 'px' }" />
<div v-show="isCut" class="back-mask">
<div v-show="showBorder" class="select-content"
:style="{ width: selectWidth + 6 + 'px', height: selectHeight + 6 + 'px',
top: startY - 3 + 'px', left: startX - 3 + 'px' }">
<img v-show="isShowSelect" :src="imgSrc" class="select-img"
:style="{ width: width + 'px', 'clip-path': clipPath }" draggable="false" />
</div>
</div>
再来说js部分
这一部分的内容主要是鼠标点击然后移动开始选择,鼠标松开就完成选择,那么就是要监听三个事件mousedown按下 mousemove移动 mouseup抬起,移动端则是touch系列的事件,通过监听鼠标的坐标来求出clip-path四个点的坐标,vue框架数据驱动就可以了
选择框的放大缩小和样式
先来讲讲红色框里面的这些要怎么处理
<div v-show="showBorder" class="select-content"
:style="{ width: selectWidth + 6 + 'px', height: selectHeight + 6 + 'px', top: startY - 3 + 'px', left: startX - 3 + 'px' }">
<div class="line top-left"
:style="{ width: Math.min(10, selectWidth * 2 / 5) + 'px',
height: Math.min(10, selectHeight * 2 / 5) + 'px' }"
@mousedown.stop="startScale"
@mousemove.stop="moveScale('tl', $event)"
@mouseup.stop="endScale"></div>
<div class="line top"></div>
<div class="line top-right"></div>
<div class="line left"></div>
<div class="line right"></div>
<div class="line bottom-left"></div>
<div class="line bottom"></div>
<div class="line bottom-right"></div>
</div>
个人处理就是用六个div定位到六个地方,也可以用图标之类的去代替
js部分的逻辑则和上面选择框差不多,不同的是这里有固定一个点和固定两个点两种情况
确认选择并裁剪
这时候我们已经得到裁剪区域四个点的坐标,那么就可以用drawImage来进行裁剪。不用clip-path的原因在于它本质上没有改变图片只是改变了显示,图片进行clip-path处理后传入drawImage展示的还是原来未被裁剪过的图片,而且我们也需要canvas来进行把裁剪内容放大和转换成文件,所以直接使用canvas全家桶是个更好的选择
首先是要清空原来canvas内容,这里说一下两种方法,一种是重新给canvas定义宽高,另一种是使用clearRect来清空内容
ctx?.clearRect(0, 0, props.width * ratio.value, props.height * ratio.value)
然后就是drawImage
除去第一个参数img以外,前四个参数和裁剪的内容有关,坐标和宽高,后四个则是裁剪后放置的坐标和宽高
最后就是把裁剪后的canvas转换成文件,可以用toDataURL和toBlob两个方法来实现,前者是转换成data协议的文件,后者则是blob,有了这两者就可以进行后续自己需要的操作
canvas.toDataURL('image/jpeg', 1)
toBlob的文档:https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toBlob
一些具体实现细节、注意事项和优化(重要)
上面只是说了一下实现的流程和技术,实际上在真正做起来的时候就会发现会有这样那样的问题
,由于是开源项目,所以我个人也是想把它做得尽可能的完善一点
canvas模糊问题,这个是canvas底层的问题了,解决办法就是将canvas放大后再把放大后的内容放入正常的规格里,可以通过scale来进行放大缩小,也可以放大canvas定义的width和height然后给他一个正常规格的style ,放大的倍率则通过获取设备像素比来求出。同时drawImage的时候也要这样子
<canvas id="wind-cut-canvas" :width="width * ratio" :height="height * ratio"
:style="{ width: width + 'px', height: height + 'px' }" />
let devicePixelRatio = window.devicePixelRatio || 1
let backingStoreRatio = ctx.webkitBackingStorePixelRatio || ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio || ctx.oBackingStorePixelRatio || ctx.backingStorePixelRatio || 1
let r = devicePixelRatio / backingStoreRatio
ratio.value = r
img.onload = function () {
ctx?.drawImage(img, 0, 0, props.width * r, scale * img.height * r)
}
坐标定位问题
无论是drawImage还是clip-path,他们的定位坐标都是相对于图片的左上角,也就是以左上角为(0,0),右下角为(img.width,img.height),可是我们事件中获取到的鼠标坐标却是相对于页面左上角的,所以要在初始化的时候获取到整个图片相对于页面的坐标,然后相减才是我们可以使用的坐标
let obj = windCut.value.getBoundingClientRect()
contentX = obj.x
contentY = obj.y
选择框初始化问题
我们在使用时是以一个点为基准点然后向四个方向去扩散,也就是说这个点可以是左上左下右上右下四个点中的任意一个,而clip-path里我们需要给出四个点的具体坐标,同时还要注意溢出问题,我们的坐标范围只能是0~图片的宽和高
//选择框宽高
let selectWidth: Ref<number> = ref(0)
let selectHeight: Ref<number> = ref(0)
function moveSelect(e: any) {
if (isSelect == 1) {
if (!isShowSelect.value) isShowSelect.value = true
selectWidth.value = Math.abs(e.x - startX.value - contentX)
selectHeight.value = Math.abs(Math.min(e.y - contentY, scale * img.height) - startY.value)
rectXY.value = [
{ x: Math.min(startX.value, Math.max(e.x - contentX, 0)),
y: Math.min(startY.value, Math.max(e.y - contentY, 0)) },
{ x: Math.max(startX.value, Math.min(e.x - contentX, props.width)),
y: Math.min(startY.value, Math.max(e.y - contentY, 0)) },
{ x: Math.max(startX.value, Math.min(e.x - contentX, props.width)),
y: Math.max(startY.value, Math.min(e.y - contentY, scale * img.height)) },
{ x: Math.min(startX.value, Math.max(e.x - contentX, 0)),
y: Math.max(startY.value, Math.min(e.y - contentY, scale * img.height)) }
]
}
moveScale('', e)
}
我这里的判断逻辑则是左上的坐标他的x和y都是最小的,其他的同理。其中startx和starty是其中一个点的具体坐标,另一个对称点的坐标则是可以通过事件给出的坐标求得,有任意两个点就可以求出剩下的两个点
选择框边框宽度问题
这是个显示的问题,如果你选择框边框的宽度足够小就可以忽视这个问题,如果边框宽度和我的差不多甚至更宽就要注意一下了,毕竟前端还是挺讲究细节的
<div v-show="showBorder" class="select-content"
:style="{ width: selectWidth + 6 + 'px', height: selectHeight + 6 + 'px', top: startY - 3 + 'px', left: startX - 3 + 'px' }">
<div class="line top-left"
:style="{ width: Math.min(10, selectWidth * 2 / 5) + 'px',
height: Math.min(10, selectHeight * 2 / 5) + 'px' }"
@mousedown.stop="startScale"
@mousemove.stop="moveScale('tl', $event)"
@mouseup.stop="endScale"></div>
</div>
我这里给选择框适当增加了点宽度和高度,定位也是要往左上靠一点,当然这里应该动态宽高比较好
同时为了防止选择框过小而导致选择框边框溢出也是进行了一下处理
选择框的放大缩小
选择框的放大缩小本质来说是固定一个点还是固定两个点的问题,比如说我们要拉伸最上面的一条边,他所改变的只是那条边上两个坐标的y,还有就是拉伸的同时左上右上两个点会变成左下右下两个点,我这里没动图,所以辛苦大家脑补一下或者用qq微信里面的截图自己玩一下,熟悉一下需求,当然你也可以不这样做,让他不能拉到下面去,我这里选择折腾版
还有当我们拉伸左上的时候,可以把它理解为往上拉伸后再往左拉伸,组合起来就可以了
let rectXY: Ref<Array<reactXYType>> = ref([])
//四个点的坐标 [leftTop, rightTop, rightBottom, leftBottom]
function moveScale(str: string, e: any) {
if (isScale) {
if (!nowRotate) {
nowRotate = str
if (!nowRotate) return
}
let leftTop = { ...rectXY.value[0] }
let rightTop = { ...rectXY.value[1] }
let rightBottom = { ...rectXY.value[2] }
let leftBottom = { ...rectXY.value[3] }
if (nowRotate.includes('t')) {
selectHeight.value = Math.abs(Math.min(Math.max(e.y - contentY, 0), scale * img.height) - leftBottom.y)
leftTop.y = Math.min(Math.max(e.y - contentY, 0), scale * img.height)
rightTop.y = Math.min(Math.max(e.y - contentY, 0), scale * img.height)
if (Math.min(Math.max(e.y - contentY, 0), scale * img.height) - leftBottom.y > 0) {//移到了下边 王车易位
let t = leftTop.y
leftTop.y = leftBottom.y
rightTop.y = rightBottom.y
leftBottom.y = t
rightBottom.y = t
nowRotate = nowRotate.replace('t', 'b')
}
}
if (nowRotate.includes('l')) {
selectWidth.value = Math.abs(Math.min(Math.max(e.x - contentX, 0), props.width) - rightBottom.x)
leftTop.x = Math.min(Math.max(e.x - contentX, 0), props.width)
leftBottom.x = Math.min(Math.max(e.x - contentX, 0), props.width)
if (Math.min(Math.max(e.x - contentX, 0), props.width) - rightBottom.x > 0) {//移到了下边
let t = leftTop.x
leftTop.x = rightBottom.x
leftBottom.x = rightBottom.x
rightTop.x = t
rightBottom.x = t
nowRotate = nowRotate.replace('l', 'r')
}
}
if (nowRotate.includes('r')) {
selectWidth.value = Math.abs(Math.min(Math.max(e.x - contentX, 0), props.width) - leftBottom.x)
rightTop.x = Math.min(Math.max(e.x - contentX, 0), props.width)
rightBottom.x = Math.min(Math.max(e.x - contentX, 0), props.width)
if (Math.min(Math.max(e.x - contentX, 0), props.width) - leftBottom.x < 0) {//移到了下边
let t = leftTop.x
leftTop.x = rightBottom.x
leftBottom.x = rightBottom.x
rightTop.x = t
rightBottom.x = t
nowRotate = nowRotate.replace('r', 'l')
}
}
if (nowRotate.includes('b')) {
selectHeight.value = Math.abs(Math.min(Math.max(e.y - contentY, 0), scale * img.height) - leftTop.y)
leftBottom.y = Math.min(Math.max(e.y - contentY, 0), scale * img.height)
rightBottom.y = Math.min(Math.max(e.y - contentY, 0), scale * img.height)
if (Math.min(Math.max(e.y - contentY, 0), scale * img.height) - leftTop.y < 0) {//移到了下边
let t = leftTop.y
leftTop.y = leftBottom.y
rightTop.y = rightBottom.y
leftBottom.y = t
rightBottom.y = t
nowRotate = nowRotate.replace('b', 't')
}
}
rectXY.value = [leftTop, rightTop, rightBottom, leftBottom]
nextTick(() => {//这两个值关系到选择框定位所以也要更新
startX.value = rectXY.value[0].x
startY.value = rectXY.value[0].y
})
}
}
裁剪比例的问题
这个问题是由于我裁剪的时候是根据原图的坐标进行裁剪,所以在使用drawImage的时候需要对当前坐标进行转换成原图对应的坐标
ctx?.drawImage(img, startX.value / scale, startY.value / scale,
selectWidth.value / scale, selectHeight.value / scale,
0, 0, props.width * ratio.value,
ratio.value * props.width * selectHeight.value / selectWidth.value)
其中scale是比例
最后
感谢各位大佬看到最后,完整源码会更新到我的开源组件库里面:
https://gitee.com/li-hanming/wind-slayer-ui
近期会更新一个放大镜的实现,感谢大家支持