vue3 移动端 使用 cropperjs 封装一个裁剪图片组件

最近移动端做一个图片裁剪功能试了一些裁剪库,多数都是对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"
        />
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容