前言
相信大家做的后台管理平台基本都会有 上传图片
的功能,有的上传的图片用于前台展示,如果不进行任何处理的话,就很有可能在前台展示的时候会出现 被压缩
的情况,当然如果对图片展示不严格的话,可以使用 css object-fit
属性,也可以对图片进行对应的裁剪,但是严格的话就需要后台上传的时候进行 裁剪
。文本裁剪
可以选择 不同比例
或者 自定义
裁剪。
实现
图解
图示以横图为示例
分析
怎么才能裁剪图片呢?
熟悉 canvas
的朋友们,应该知道 drawImage 方法,可以进行图片绘制。
// 可以点击 drawImage 方法查看文档,文档详细描述了这些参数代表的意思
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
接下来就是需要知道如何得到这些参数的值?
首先我们能拿到上传后的 file
图片文件,我们需要知道 原图尺寸
,可以通过 new Image()
创建 img
元素,从而可以知道 原图尺寸
,然后就可以得到一系列我们需要的参数。
前提知识:查看 FileReader
const reader = new FileReader()
reader.onload = (e) => {
// 创建 img 元素,是为了知道原图的尺寸
const img = new Image()
img.src = e.target.result
// 当图片成功加载时的回调函数
img.onload = () => {
this.image = e.target.result
// 需要放在 $nextTick中,等待 image 加载完成后执行
this.$nextTick(() => {
// 知道图片原始尺寸,从而判断是横图还是竖图,计算裁剪框的最大裁剪尺寸以及偏移量
this.handleImageDimensions(img)
})
}
}
// 将文件读取为DataURL(base64编码的字符串)
reader.readAsDataURL(file)
handleImageDimensions
方法的实现
// 判断是横图还是竖图
const isHorizontal = img.width > img.height
// 计算图片的宽高比,用于确定最大裁剪尺寸
const ratio = isHorizontal ? img.height / img.width : img.width / img.height
// 根据容器尺寸和宽高比计算最大裁剪尺寸
const maxCropSize = ratio * this.imageContainerSize
this.isHorizontal = isHorizontal
// 设置裁剪框到的尺寸
this.sizeStyles = isHorizontal
? { width: this.imageContainerSize + 'px', height: maxCropSize + 'px' }
: { height: this.imageContainerSize + 'px', width: maxCropSize + 'px' }
this.maxCropSize = maxCropSize
// 设置裁剪框的偏移量,使其在图片中位置
this.cropX = isHorizontal ? (this.imageContainerSize - maxCropSize) / 2 : 0
this.cropY = isHorizontal ? 0 : (this.imageContainerSize - maxCropSize) / 2
// 裁剪图片
this.cropImage()
至此,我们已经得到了所有所需参数的值,可以使用 canvas
进行裁剪
const img = new Image()
img.src = this.image
img.onload = () => {
const croppedCanvas = document.createElement('canvas')
const croppedCtx = croppedCanvas.getContext('2d')
// 设置画布的尺寸
croppedCanvas.width = this.cropWidth
croppedCanvas.height = this.cropHeight
// 缩放比例
let imgScale = 1
// 得到原图与裁剪框的缩放比例
if (this.isHorizontal) {
imgScale = Math.min(
img.width / this.imageContainerSize,
img.height / this.maxCropSize
)
} else {
imgScale = Math.min(
img.width / this.maxCropSize,
img.height / this.imageContainerSize
)
}
// 从原图中裁剪出指定区域
croppedCtx.drawImage(
img,
this.cropX * imgScale,
this.cropY * imgScale,
this.cropWidth * imgScale,
this.cropHeight * imgScale,
0,
0,
this.cropWidth,
this.cropHeight
)
// 获取裁剪后的图片数据
this.croppedImage = croppedCanvas.toDataURL()
}
现在已经实现了图片的裁剪功能,但是会发现裁剪出来的图片会失真,这是为什么呢?原因是我们裁剪出来的图片很小,但是在大的画布上展示,图片被放大了,肯定就会失真,解决也非常简单,把上面的代码稍微改动一下就可以了
// 改动以下两个地方即可
croppedCanvas.width = this.cropWidth * imgScale
croppedCanvas.height = this.cropHeight * imgScale
croppedCtx.drawImage(
img,
this.cropX * imgScale,
this.cropY * imgScale,
this.cropWidth * imgScale,
this.cropHeight * imgScale,
0,
0,
this.cropWidth * imgScale,
this.cropHeight * imgScale
)
对比一下就会很明显,裁剪的越小就会越明显
至此,裁剪图片的功能就已经完成,但是光裁剪可不行,不能裁剪手动指定的区域以及不能缩放裁剪框,所以接下来就是实现裁剪框拖拽以及缩放
实现拖动裁剪框
// 监听鼠标按下裁剪框的事件,可以得到鼠标按下的坐标 startX 以及 startY
// 开启鼠标移动事件,可以得到鼠标移动的距离
const dx = event.clientX - this.startX
const dy = event.clientY - this.startY
this.cropX += dx // 裁剪框的横向偏移量
this.cropY += dy // 裁剪框的纵向偏移量
// 处理边界情况,不能拖拽出图片的区域
// 处理左边界以及上边界
if (this.cropX < 0) this.cropX = 0
if (this.cropY < 0) this.cropY = 0
// 处理右边界以及下边界
if (this.isHorizontal) {
if (this.cropX + this.cropWidth > this.imageContainerSize) {
this.cropX = this.imageContainerSize - this.cropWidth
}
if (this.cropY + this.cropHeight > this.maxCropSize) {
this.cropY = this.maxCropSize - this.cropHeight
}
} else {
if (this.cropX + this.cropWidth > this.maxCropSize) {
this.cropX = this.maxCropSize - this.cropWidth
}
if (this.cropY + this.cropHeight > this.imageContainerSize) {
this.cropY = this.imageContainerSize - this.cropHeight
}
}
实现裁剪框缩放
// 监听鼠标按下等比例缩放按钮的事件,可以得到鼠标按下的坐标 startX 以及 startY
// 然后开启鼠标移动事件,可以得到鼠标移动的距离
const dx = event.clientX - this.startX
const dy = event.clientY - this.startY
// 设置裁剪框的大小
this.cropWidth += dx
this.cropHeight += dx
// 限制裁剪区域的最小尺寸
if (this.cropWidth < 50) this.cropWidth = 50
if (this.cropHeight < 50) this.cropHeight = 50
// 限制裁剪框不超出图片范围
// prop.length !== 0 是为了解决自定义裁剪框的边界问题,为 0 表示是自定义裁剪,就表示只需要设置宽或高,不用等比例缩放
if (this.isHorizontal) {
// 横图的情况下,宽度不能超过容器宽度,高度不能超过图片高度
if (this.cropX + this.cropWidth > this.imageContainerSize) {
this.cropWidth = this.imageContainerSize - this.cropX
if (prop.length !== 0) {
this.cropHeight = this.cropWidth * (prop[1] / prop[0])
}
}
if (this.cropY + this.cropHeight > this.maxCropSize) {
this.cropHeight = this.maxCropSize - this.cropY
if (prop.length !== 0) {
this.cropWidth = this.cropHeight * (prop[0] / prop[1])
}
}
} else {
// 竖图的情况下,宽度不能超过图片宽度,高度不能超过容器高度
if (this.cropX + this.cropWidth > this.maxCropSize) {
this.cropWidth = this.maxCropSize - this.cropX
if (prop.length !== 0) {
this.cropHeight = this.cropWidth * (prop[1] / prop[0])
}
}
if (this.cropY + this.cropHeight > this.imageContainerSize) {
this.cropHeight = this.imageContainerSize - this.cropY
if (prop.length !== 0) {
this.cropWidth = this.cropHeight * (prop[0] / prop[1])
}
}
}
每次切换比例时,需要重新计算裁剪框的尺寸以及位置
// 修改裁剪框的尺寸
switch (this.aspectRatio) {
case '1/1':
this.cropWidth = this.cropHeight = this.maxCropSize // 1:1
break
case '4/3':
this.cropWidth = this.isHorizontal ? this.maxCropSize * (4 / 3) : this.maxCropSize
this.cropHeight = this.isHorizontal ? this.maxCropSize : this.maxCropSize * (3 / 4)
break
case '16/9':
this.cropWidth = this.isHorizontal ? this.maxCropSize * (16 / 9) : this.maxCropSize
this.cropHeight = this.isHorizontal ? this.maxCropSize : this.maxCropSize * (9 / 16)
break
case 'custom':
this.cropHeight = this.cropWidth = this.maxCropSize / 2
break
}
// 修改裁剪框的位置
this.cropX = this.isHorizontal ? (this.imageContainerSize - this.cropWidth) / 2 : 0
this.cropY = this.isHorizontal ? 0 : (this.imageContainerSize - this.cropHeight) / 2
上文有很多地方是可以优化,比如:判断逻辑、硬编码、层次结构等等,是为了更加通俗易懂,也可以对照下方优化后的代码进行阅读
优化后完整代码
<template>
<div class="avatar-cropper">
<div class="upload-section">
<input type="file" @change="onFileChange" accept="image/*" />
</div>
<div v-if="image" class="cropper-section">
<div class="image-container">
<div class="image-box" :style="sizeStyles">
<img :src="image" class="uploaded-image" :style="sizeStyles" />
<div
class="crop-area"
:style="{
width: cropWidth + 'px',
height: cropHeight + 'px',
left: cropX + 'px',
top: cropY + 'px'
}"
@mousedown="startCrop"
>
<div class="resize-handle" @mousedown.stop="startResize"></div>
</div>
</div>
</div>
<div class="controls">
<label for="aspect-ratio">选择裁剪比例:</label>
<select v-model="aspectRatio" @change="updateCropDimensions">
<option value="1/1">1:1</option>
<option value="4/3">4:3</option>
<option value="16/9">16:9</option>
<option value="custom">自定义</option>
</select>
</div>
</div>
<div v-if="croppedImage" class="result-section">
<h3 class="result-title">裁剪结果:</h3>
<div class="preview-image">
<img :src="croppedImage" class="result-image" />
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
image: null,
croppedImage: null,
imageContainerSize: 300,
cropX: 0, // 裁剪区域的X坐标
cropY: 0, // 裁剪区域的Y坐标
cropWidth: 0, // 裁剪区域的宽度
cropHeight: 0, // 裁剪区域的高度
aspectRatio: '1/1', // 裁剪比例
maxCropSize: 0, // 最大裁剪尺寸
isDragging: false,
isResizing: false,
startX: 0,
startY: 0,
sizeStyles: {}, // 图片容器的样式
isHorizontal: false // 是否为横向图片
}
},
methods: {
// 文件选择事件处理函数
onFileChange(event) {
// 获取用户选择的第一个文件
const file = event.target.files[0]
// 如果有文件被选择
if (file) {
// 创建一个FileReader对象来读取文件
const reader = new FileReader()
// 当文件被成功读取时的回调函数
reader.onload = (e) => {
// 创建一个Image对象来加载读取的文件数据
const img = new Image()
// 设置Image对象的src属性为读取的文件数据
img.src = e.target.result
// 当图片成功加载时的回调函数
img.onload = () => {
this.image = e.target.result
// 使用$nextTick等待图片渲染完成后处理图片尺寸
this.$nextTick(() => {
// 调用处理图片尺寸的方法
this.handleImageDimensions(img)
})
}
}
// 使用FileReader对象的readAsDataURL方法读取文件
reader.readAsDataURL(file)
}
},
// 处理图片尺寸,确定裁剪尺寸和裁剪区域位置
handleImageDimensions(img) {
// 检查图片尺寸是否为零,因为这将导致无法进行裁剪操作
if (img.width === 0 || img.height === 0) {
console.error('Image dimension is zero, cannot proceed.')
return
}
// 判断图片是横向还是纵向,以便后续计算裁剪尺寸
const isHorizontal = img.width > img.height
// 计算图片的宽高比,用于确定最大裁剪尺寸
const ratio = isHorizontal ? img.height / img.width : img.width / img.height
// 根据容器尺寸和宽高比计算最大裁剪尺寸
const maxCropSize = ratio * this.imageContainerSize
// 更新组件的状态,指示图片是横向还是纵向
this.isHorizontal = isHorizontal
// 根据图片方向设置尺寸样式,确保裁剪区域正确显示
this.sizeStyles = isHorizontal
? { width: this.imageContainerSize + 'px', height: maxCropSize + 'px' }
: { height: this.imageContainerSize + 'px', width: maxCropSize + 'px' }
// 保存最大裁剪尺寸以供后续使用
this.maxCropSize = maxCropSize
// 更新裁剪维度,确保裁剪区域与图片方向和尺寸匹配
this.updateCropDimensions()
// 根据图片方向计算裁剪区域的X和Y坐标,以中心裁剪的方式定位裁剪区域
this.cropX = isHorizontal ? (this.imageContainerSize - maxCropSize) / 2 : 0
this.cropY = isHorizontal ? 0 : (this.imageContainerSize - maxCropSize) / 2
// 执行裁剪操作
this.cropImage()
},
// 更新裁剪区域的尺寸
updateCropDimensions() {
// 调用calculateDimensions方法计算裁剪区域的宽度和高度
const { width: cropWidth, height: cropHeight } = this.calculateDimensions(
this.aspectRatio,
this.maxCropSize,
this.isHorizontal
)
// 更新裁剪区域的宽度和高度
this.cropWidth = cropWidth
this.cropHeight = cropHeight
// 根据图像容器的尺寸和裁剪区域的尺寸,计算裁剪区域的X轴和Y轴位置
this.cropX = this.isHorizontal ? (this.imageContainerSize - cropWidth) / 2 : 0
this.cropY = this.isHorizontal ? 0 : (this.imageContainerSize - cropHeight) / 2
},
// 计算裁剪区域的尺寸
calculateDimensions(aspectRatio, maxCropSize, isHorizontal) {
// 根据不同的纵横比(aspectRatio),计算裁剪区域的宽度和高度
switch (aspectRatio) {
case '1/1':
// 对于1:1的纵横比,宽度和高度都等于最大裁剪尺寸
return { width: maxCropSize, height: maxCropSize }
case '4/3':
// 对于4:3的纵横比,根据是否是水平方向(isHorizontal)来决定宽度和高度
return isHorizontal
? { width: maxCropSize * (4 / 3), height: maxCropSize }
: { width: maxCropSize, height: maxCropSize * (3 / 4) }
case '16/9':
// 对于16:9的纵横比,同样根据是否是水平方向来决定宽度和高度
return isHorizontal
? { width: maxCropSize * (16 / 9), height: maxCropSize }
: { width: maxCropSize, height: maxCropSize * (9 / 16) }
case 'custom':
// 对于自定义的纵横比,宽度和高度都取最大裁剪尺寸的一半
return { width: maxCropSize / 2, height: maxCropSize / 2 }
default:
// 如果传入了无效的纵横比,抛出错误
throw new Error('无效的 aspectRatio')
}
},
// 裁剪图片
cropImage() {
const img = new Image()
img.src = this.image
img.onload = () => {
const croppedCanvas = document.createElement('canvas')
const croppedCtx = croppedCanvas.getContext('2d')
let imgScale = 1
// 计算图片的缩放比例
if (this.isHorizontal) {
imgScale = Math.min(
img.width / this.imageContainerSize,
img.height / this.maxCropSize
)
} else {
imgScale = Math.min(
img.width / this.maxCropSize,
img.height / this.imageContainerSize
)
}
// 设置裁剪画布的大小!!!一定要设置剩余比例!!!画布大小也要剩余比例!!!否则裁剪的图片大小是对的,但是缩放到的比例不对,会导致裁剪图片失真
croppedCanvas.width = this.cropWidth * imgScale
croppedCanvas.height = this.cropHeight * imgScale
// 计算裁剪区域在原图中的位置
const scaledCropX = this.cropX * imgScale
const scaledCropY = this.cropY * imgScale
// 从原图中裁剪出指定区域
croppedCtx.drawImage(
img,
scaledCropX,
scaledCropY,
this.cropWidth * imgScale,
this.cropHeight * imgScale,
0,
0,
this.cropWidth * imgScale,
this.cropHeight * imgScale
)
// 获取裁剪后的图片数据
this.croppedImage = croppedCanvas.toDataURL()
}
},
startCrop(event) {
this.isDragging = true
this.startX = event.clientX
this.startY = event.clientY
document.addEventListener('mousemove', this.onMouseMove)
document.addEventListener('mouseup', this.stopCrop)
},
// 鼠标移动事件处理函数
onMouseMove(event) {
// 计算鼠标相对于起始位置的偏移量
const dx = event.clientX - this.startX
const dy = event.clientY - this.startY
// 根据当前操作状态更新裁剪区域的位置或大小
if (this.isDragging) {
this.updateCropPosition(dx, dy)
} else if (this.isResizing) {
this.updateCropSize(dx, dy)
}
// 更新起始位置为当前鼠标位置,为下一次移动做准备
this.startX = event.clientX
this.startY = event.clientY
},
// 更新裁剪区域的位置
updateCropPosition(dx, dy) {
// 根据鼠标偏移量更新裁剪区域的位置
this.cropX += dx
this.cropY += dy
// 确保裁剪区域的位置在图像容器范围内
this.cropX = this.isHorizontal
? Math.max(0, Math.min(this.cropX, this.imageContainerSize - this.cropWidth))
: Math.max(0, Math.min(this.cropX, this.maxCropSize - this.cropWidth))
this.cropY = this.isHorizontal
? Math.max(0, Math.min(this.cropY, this.maxCropSize - this.cropHeight))
: Math.max(0, Math.min(this.cropY, this.imageContainerSize - this.cropHeight))
},
// 更新裁剪区域的大小
updateCropSize(dx, dy) {
const minCropSize = 50
let prop = []
// 根据不同的纵横比更新裁剪区域的大小
switch (this.aspectRatio) {
case '1/1':
this.cropWidth += dx
this.cropHeight += dx
prop = [1, 1]
break
case '4/3':
this.cropWidth += dx
this.cropHeight = this.cropWidth * (3 / 4)
prop = [4, 3]
break
case '16/9':
this.cropWidth += dx
this.cropHeight = this.cropWidth * (9 / 16)
prop = [16, 9]
break
default:
this.cropWidth += dx
this.cropHeight += dy
break
}
// 确保裁剪区域的大小不超过图像容器的限制
this.cropWidth = this.isHorizontal
? Math.min(this.cropWidth, this.imageContainerSize - this.cropX)
: Math.min(this.cropWidth, this.maxCropSize - this.cropX)
this.cropHeight = this.isHorizontal
? Math.min(this.cropHeight, this.maxCropSize - this.cropY)
: Math.min(this.cropHeight, this.imageContainerSize - this.cropY)
// 计算裁剪区域的最大大小
const maxCropWidthSize = this.isHorizontal ? this.imageContainerSize : this.maxCropSize
const maxCropHeightSize = this.isHorizontal ? this.maxCropSize : this.imageContainerSize
// 如果有指定纵横比,确保裁剪区域不会超出图像容器或者不会小于minCropSize
if (prop.length !== 0) {
if (this.cropX + this.cropWidth >= maxCropWidthSize) {
this.cropWidth = maxCropWidthSize - this.cropX
this.cropHeight = this.cropWidth * (prop[1] / prop[0])
}
if (this.cropWidth < minCropSize) {
this.cropWidth = minCropSize
this.cropHeight = this.cropWidth * (prop[1] / prop[0])
}
if (this.cropY + this.cropHeight >= maxCropHeightSize) {
this.cropHeight = maxCropHeightSize - this.cropY
this.cropWidth = this.cropHeight * (prop[0] / prop[1])
}
if (this.cropHeight < minCropSize) {
this.cropHeight = minCropSize
this.cropWidth = this.cropHeight * (prop[0] / prop[1])
}
} else {
// 确保自定义时,裁剪区域的最小大小
this.cropWidth = Math.max(minCropSize, this.cropWidth)
this.cropHeight = Math.max(minCropSize, this.cropHeight)
}
},
stopCrop() {
this.isDragging = false
this.isResizing = false
this.cropImage()
document.removeEventListener('mousemove', this.onMouseMove)
document.removeEventListener('mouseup', this.stopCrop)
},
startResize(event) {
this.isResizing = true
this.startX = event.clientX
this.startY = event.clientY
document.addEventListener('mousemove', this.onMouseMove)
document.addEventListener('mouseup', this.stopCrop)
}
}
}
</script>
<style lang="scss" scoped>
.avatar-cropper {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
background-color: #f5f5f5;
}
.upload-section {
margin-bottom: 20px;
}
.cropper-section {
margin-bottom: 20px;
position: relative;
}
.image-container {
position: relative;
width: 300px;
height: 300px;
overflow: hidden;
border: 1px dashed #ccc;
background-color: #000;
.image-box {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.uploaded-image {
position: absolute;
}
.crop-area {
position: absolute;
border: 2px dashed #007bff;
background-color: rgba(0, 123, 255, 0.3);
cursor: move;
.resize-handle {
position: absolute;
width: 10px;
height: 10px;
background-color: #007bff;
right: -5px;
bottom: -5px;
cursor: nwse-resize;
}
}
}
}
.controls {
margin-top: 10px;
}
.result-section {
margin-top: 20px;
.result-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.preview-image {
width: 100px;
height: 100px;
.result-image {
width: 100%;
border: 1px solid #ccc;
}
}
}
</style>
结语
至此,关于图片裁剪的功能也全部完工了