最近移动端做一个图片裁剪功能试了一些裁剪库,多数都是对PC友好,对h5的支持不太行,最后选择了cropperjs ,写起来还挺简单的,开始我的爬坑之旅
首先是UI画的页面
<template>
<van-popup v-model:show="popupShow" position="bottom" :style="{ height: '100%' }" @close="handleClose">
<div class="cropper-container">
<div class="cropper-wrapper">
<img ref="imageRef" :src="imageSrc" alt="待裁剪图片" crossorigin="anonymous">
</div>
<div class="toolbar-box">
<div class="toolbar">
<div class="rotate-buttons">
<img src="./img/rotate.png" alt="" @click="rotate(-90)">
</div>
<div class="reset-button" @click="reset">
还原
</div>
</div>
<div class="action-buttons">
<div @click="handleCancel" class="cancel-button">
<img src="./img/shut-down.png" alt="">
</div>
<div class="confirm-button" @click="handleConfirm">
确认
</div>
</div>
</div>
</div>
</van-popup>
<toast-loading v-model:show="loading" />
</template>
然后初始化
import Cropper from "cropperjs";
import "cropperjs/dist/cropper.css";
// 检测图片方向
const detectImageOrientation = (url, callback) => {
const img = new Image();
img.onload = function () {
callback(this.width < this.height);
};
img.src = url;
};
// 初始化Cropper
const initCropper = () => {
if (imageRef.value && props.imageSrc) {
detectImageOrientation(props.imageSrc, (portrait) => {
cropper = new Cropper(imageRef.value, {
// aspectRatio: portrait ? props.portraitRatio : props.landscapeRatio,
viewMode: 1, // 限制裁剪框不超过画布
dragMode: "move",
autoCropArea: 1, // 自动填充100%
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
highlight: false,
background: false,
guides: false,
scalable: true,
zoomable: true,
aspectRatio: NaN,
responsive: true,
ready() {
// 设置裁剪框样式
const cropBox = this.cropper.cropBox.querySelector(".cropper-view-box");
if (cropBox) {
cropBox.style.outlineColor = "#fff";
}
// 修改手柄颜色
const points = this.cropper.cropBox.querySelectorAll(".cropper-point");
points.forEach(point => {
point.style.backgroundColor = "#fff";
});
}
});
});
}
};
本来一切都还挺顺利的,但是我有个旋转功能,开始我的爬坑之旅了
首先一开始进来的时候还挺好的,但是长图旋转之后,图片超出了我的容器我各种百度和AI,都不能实现的我的问题,后来使用了zoomTo,使用之后会导致我的裁剪框和图片不重叠, 后来我去查看cropperjs 源码,他的旋转之后,并没有重置容器的尺寸,好的,找到问题,解决问题,集成他的旋转,然后修改
class FixedCropper extends Cropper {
constructor(element, options) {
super(element, options);
this.originalViewMode = options.viewMode;
this.originalAspectRatio = options.aspectRatio;
}
rotate(degree) {
if (!this.ready || this.disabled) return this;
// 保存当前裁剪框状态
const currentCropBox = this.getCropBoxData();
// 执行旋转
super.rotate(degree);
// 重新计算容器适配
this.calculateContainerFit();
// 恢复裁剪框位置和尺寸
this.setCropBoxData(currentCropBox);
return this;
}
// 新增:计算容器适配
calculateContainerFit() {
const { containerData } = this;
const imageData = this.getImageData();
// 计算旋转后的实际尺寸
const rotated = Math.abs(imageData.rotate) % 180 === 90;
const naturalWidth = rotated ? imageData.naturalHeight : imageData.naturalWidth;
const naturalHeight = rotated ? imageData.naturalWidth : imageData.naturalHeight;
const aspectRatio = naturalWidth / naturalHeight;
// 计算适配容器的尺寸
let fitWidth, fitHeight;
if (containerData.width / containerData.height > aspectRatio) {
fitHeight = containerData.height;
fitWidth = fitHeight * aspectRatio;
} else {
fitWidth = containerData.width;
fitHeight = fitWidth / aspectRatio;
}
// 更新画布尺寸和位置
this.setCanvasData({
width: fitWidth,
height: fitHeight,
left: (containerData.width - fitWidth) / 2,
top: (containerData.height - fitHeight) / 2
});
}
}
最后的代码实现如下
<template>
<van-popup v-model:show="popupShow" position="bottom" :style="{ height: '100%' }" @close="handleClose">
<div class="cropper-container">
<div class="cropper-wrapper">
<img ref="imageRef" :src="imageSrc" alt="待裁剪图片" crossorigin="anonymous">
</div>
<div class="toolbar-box">
<div class="toolbar">
<div class="rotate-buttons">
<img src="./img/rotate.png" alt="" @click="rotate(-90)">
</div>
<div class="reset-button" @click="reset">
还原
</div>
</div>
<div class="action-buttons">
<div @click="handleCancel" class="cancel-button">
<img src="./img/shut-down.png" alt="">
</div>
<div class="confirm-button" @click="handleConfirm">
确认
</div>
</div>
</div>
</div>
</van-popup>
<toast-loading v-model:show="loading" />
</template>
<script setup>
import ToastLoading from "@/components/toast/toast-loading.vue";
import { uploadBase64Image } from "@/tool/upload";
import Cropper from "cropperjs";
import "cropperjs/dist/cropper.css";
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
import { Toast } from "vant";
const props = defineProps({
show: {
type: Boolean,
default: false
},
imageSrc: {
type: String,
required: true
}
});
const emit = defineEmits(["update:show", "confirm"]);
const imageRef = ref(null);
let cropper = null;
const popupShow = ref(false);
const loading = ref(false);
// 检测图片方向
const detectImageOrientation = (url, callback) => {
const img = new Image();
img.onload = function () {
callback(this.width < this.height);
};
img.src = url;
};
class FixedCropper extends Cropper {
constructor(element, options) {
super(element, options);
this.originalViewMode = options.viewMode;
this.originalAspectRatio = options.aspectRatio;
}
rotate(degree) {
if (!this.ready || this.disabled) return this;
// 保存当前裁剪框状态
const currentCropBox = this.getCropBoxData();
// 执行旋转
super.rotate(degree);
// 重新计算容器适配
this.calculateContainerFit();
// 恢复裁剪框位置和尺寸
this.setCropBoxData(currentCropBox);
return this;
}
// 新增:计算容器适配
calculateContainerFit() {
const { containerData } = this;
const imageData = this.getImageData();
// 计算旋转后的实际尺寸
const rotated = Math.abs(imageData.rotate) % 180 === 90;
const naturalWidth = rotated ? imageData.naturalHeight : imageData.naturalWidth;
const naturalHeight = rotated ? imageData.naturalWidth : imageData.naturalHeight;
const aspectRatio = naturalWidth / naturalHeight;
// 计算适配容器的尺寸
let fitWidth, fitHeight;
if (containerData.width / containerData.height > aspectRatio) {
fitHeight = containerData.height;
fitWidth = fitHeight * aspectRatio;
} else {
fitWidth = containerData.width;
fitHeight = fitWidth / aspectRatio;
}
// 更新画布尺寸和位置
this.setCanvasData({
width: fitWidth,
height: fitHeight,
left: (containerData.width - fitWidth) / 2,
top: (containerData.height - fitHeight) / 2
});
}
}
// 初始化Cropper
const initCropper = () => {
if (imageRef.value && props.imageSrc) {
detectImageOrientation(props.imageSrc, (portrait) => {
// isPortrait = portrait;
cropper = new FixedCropper(imageRef.value, {
// aspectRatio: portrait ? props.portraitRatio : props.landscapeRatio,
viewMode: 1, // 限制裁剪框不超过画布
dragMode: "move",
autoCropArea: 1, // 自动填充100%
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
highlight: false,
background: false,
guides: false,
scalable: true,
zoomable: true,
aspectRatio: NaN,
responsive: true,
ready() {
// 设置裁剪框样式
const cropBox = this.cropper.cropBox.querySelector(".cropper-view-box");
if (cropBox) {
cropBox.style.outlineColor = "#fff";
}
// 修改手柄颜色
const points = this.cropper.cropBox.querySelectorAll(".cropper-point");
points.forEach(point => {
point.style.backgroundColor = "#fff";
});
}
});
});
}
};
// 旋转图片
const rotate = (degree) => {
cropper.options.viewMode = 0;
if (cropper) {
cropper.rotate(degree);
}
cropper.options.viewMode = 1;
// 旋转后重新填充
const containerData = cropper.getContainerData();
cropper.setCropBoxData({
left: 0,
top: 0,
width: containerData.width,
height: containerData.height
});
};
// 重置裁剪
const reset = () => {
if (cropper) {
cropper.reset();
// 重置后重新填充
const containerData = cropper.getContainerData();
cropper.setCropBoxData({
left: 0,
top: 0,
width: containerData.width,
height: containerData.height
});
}
};
// 销毁Cropper
const destroyCropper = () => {
if (cropper) {
cropper.destroy();
cropper = null;
}
};
// 确认裁剪
const handleConfirm = () => {
if (loading.value) return;
if (!cropper) {
Toast("裁剪器未初始化");
return;
}
loading.value = true;
const croppedCanvas = cropper.getCroppedCanvas({
fillColor: "#fff",
imageSmoothingEnabled: true,
imageSmoothingQuality: "high"
});
if (croppedCanvas) {
const croppedImageUrl = croppedCanvas.toDataURL("image/jpeg", 1);
uploadBase64Image(croppedImageUrl).then((url) => {
loading.value = false;
emit("confirm", url);
emit("update:show", false);
});
} else {
loading.value = false;
Toast("裁剪失败,请重试");
}
};
// 取消裁剪
const handleCancel = () => {
popupShow.value = false;
};
// 关闭弹框
const handleClose = () => {
popupShow.value = false;
};
// 监听显示状态变化
watch(() => props.show, async(val) => {
if (val) {
await nextTick();
setTimeout(() => {
destroyCropper();
initCropper();
}, 150);
} else {
destroyCropper();
}
popupShow.value = val;
});
watch(() => popupShow.value, (val) => {
emit("update:show", val);
});
onBeforeUnmount(() => {
destroyCropper();
});
</script>
<style scoped lang="scss">
/* 容器样式 */
.cropper-container {
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 1);
position: relative;
}
:deep(.cropper-center) {
display: none !important;
}
:deep(.point-e) {
display: none !important;
}
// .point-n, .point-w, .point-s, .point-ne, .point-nw, .point-sw
:deep(.point-n){
display: none !important;
}
:deep(.point-w){
display: none !important;
}
:deep(.point-s){
display: none !important;
}
:deep(.point-ne){
display: none !important;
}
:deep(.point-nw){
display: none !important;
}
:deep(.point-sw){
display: none !important;
}
/* 裁剪区域样式(关键修改) */
.cropper-wrapper {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 130px;
overflow: hidden;
}
/* 图片样式(重要修改) */
.cropper-wrapper img {
display: block;
max-width: none !important;
max-height: none !important;
width: 100%;
height: 100%;
object-fit: contain;
}
/* 覆盖cropperjs默认样式 */
:deep(.cropper-container) {
position: absolute;
top: 0;
left: 0;
width: 100% !important;
height: 100% !important;
}
:deep(.cropper-crop-box),
:deep(.cropper-view-box) {
z-index: 20px;
outline: 2px solid white !important;
}
.toolbar-box{
position: absolute;
bottom: 6px;
left: 0;
right: 0;
width: 100%;
z-index: 10;
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
padding: 0 20px 30px 20px;
.rotate-buttons {
img {
width: 28px;
height: 28px;
display: block;
}
}
.reset-button {
font-weight: 600;
font-size: 16px;
color: #FFFFFF;
line-height: 22px;
}
}
.action-buttons {
display: flex;
justify-content: space-between;
height: 60px;
align-items: center;
border-top: 1px solid #767676;
padding: 0 20px;
.cancel-button {
img {
width: 24px;
height: 24px;
display: block;
}
}
.confirm-button{
font-weight: 600;
font-size: 16px;
color: #1A1A1A;
line-height: 18px;
text-align: center;
background: linear-gradient( 90deg, #FFE665 0%, #FFD500 100%);
border-radius: 18px;
padding: 9px 28px;
}
}
}
</style>
组件调用
<!-- 裁剪弹框 -->
<image-cropper
v-model:show="showCropper"
:image-src="uploadedImage"
@confirm="handleCropConfirm"
/>