2026-05-09 10:34:51 +08:00

262 lines
7.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="visible" class="image-cropper-mask">
<div class="cropper-box">
<vue-cropper
ref="cropperRef"
:img="imgUrl"
:output-size="option.size"
:output-type="option.outputType"
:info="true"
:full="option.full"
:can-move="option.canMove"
:can-move-box="option.canMoveBox"
:original="option.original"
:auto-crop="option.autoCrop"
:fixed="option.fixed"
:fixed-number="option.fixedNumber"
:center-box="option.centerBox"
:info-true="option.infoTrue"
:fixed-box="option.fixedBox"
:mode="option.mode"
:auto-crop-width="option.autoCropWidth"
:auto-crop-height="option.autoCropHeight"
@img-load="imgLoad"
></vue-cropper>
<div v-if="imgLoading" class="loading-mask">
<Icon name="global_loading" loading size="96" />
</div>
</div>
<div class="tools">
<mj-button @click="rotateLeft">左旋转</mj-button>
<mj-button @click="rotateRight">右旋转</mj-button>
<mj-button type="warn" @click="handleCancel">取消</mj-button>
<mj-button type="primary" :loading="loading" @click="handleConfirm">确认</mj-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, nextTick } from 'vue'
import 'vue-cropper/dist/index.css'
import { VueCropper } from 'vue-cropper'
const props = defineProps<{
visible: boolean
img: string | Blob | File
}>()
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
const cropperRef = ref()
const imgUrl = ref('')
const loading = ref(false)
const imgLoading = ref(true)
// 平板/低端设备建议较小尺寸,减小处理与上传耗时
const MAX_IMG_DIMENSION = 1280
const option = reactive({
size: 0.85,
full: false,
outputType: 'jpeg',
canMove: true,
original: false,
canMoveBox: true,
autoCrop: true,
autoCropWidth: 0,
autoCropHeight: 0,
centerBox: false,
high: true,
maxImgSize: MAX_IMG_DIMENSION,
fixed: false,
fixedNumber: [1, 1],
infoTrue: false,
fixedBox: false,
mode: 'contain',
})
// 用于校验异步过程中 props.img 是否被替换,避免设置过时数据
let loadToken = 0
function readFileAsDataURL(file: Blob | File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = e => resolve((e.target?.result as string) || '')
reader.onerror = reject
reader.readAsDataURL(file)
})
}
// 优先使用 createImageBitmap异步解码、更快不可用时退回 Image 加载
async function loadImageSize(
source: Blob | File | string,
): Promise<{ bitmap?: ImageBitmap; img?: HTMLImageElement; w: number; h: number }> {
// File 继承自 Blob因此只需判断 Blob 即可同时覆盖 File
if (typeof createImageBitmap === 'function' && typeof source !== 'string') {
try {
const bitmap = await createImageBitmap(source)
return { bitmap, w: bitmap.width, h: bitmap.height }
} catch {
/* fallthrough */
}
}
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve({ img, w: img.naturalWidth, h: img.naturalHeight })
img.onerror = reject
if (typeof source === 'string') {
img.src = source
} else {
const reader = new FileReader()
reader.onload = e => {
img.src = (e.target?.result as string) || ''
}
reader.onerror = reject
reader.readAsDataURL(source)
}
})
}
async function downscaleImage(source: Blob | File | string): Promise<string> {
try {
const { bitmap, img, w, h } = await loadImageSize(source)
const drawSource = (bitmap || img) as CanvasImageSource
const needScale = w > MAX_IMG_DIMENSION || h > MAX_IMG_DIMENSION
const scale = needScale ? Math.min(MAX_IMG_DIMENSION / w, MAX_IMG_DIMENSION / h) : 1
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(w * scale))
canvas.height = Math.max(1, Math.round(h * scale))
const ctx = canvas.getContext('2d')!
ctx.drawImage(drawSource, 0, 0, canvas.width, canvas.height)
bitmap?.close?.()
return canvas.toDataURL('image/jpeg', 0.9)
} catch (e) {
console.error('downscaleImage error', e)
if (typeof source === 'string') return source
try {
return await readFileAsDataURL(source)
} catch {
return ''
}
}
}
watch(
() => props.img,
async (val: string | Blob | File | undefined) => {
// 立即清空旧图,避免在异步处理新图时显示上一张图
imgUrl.value = ''
imgLoading.value = true
if (!val) return
const myToken = ++loadToken
try {
const url = await downscaleImage(val)
// 防止后来的图片切换被覆盖
if (myToken !== loadToken) return
imgUrl.value = url
} catch (e) {
console.error('cropper image load error', e)
if (myToken === loadToken) imgLoading.value = false
}
},
{ immediate: true },
)
function imgLoad(msg: string) {
if (msg === 'success') {
imgLoading.value = false
nextTick(() => {
if (cropperRef.value) {
const { trueWidth, trueHeight, scale } = cropperRef.value
option.autoCropWidth = trueWidth * scale
option.autoCropHeight = trueHeight * scale
option.centerBox = true
nextTick(() => {
cropperRef.value.goAutoCrop()
})
}
})
}
}
function rotateLeft() {
cropperRef.value.rotateLeft()
}
function rotateRight() {
cropperRef.value.rotateRight()
}
function handleCancel() {
emit('update:visible', false)
emit('cancel')
}
function handleConfirm() {
loading.value = true
cropperRef.value.getCropBlob((data: Blob) => {
loading.value = false
emit('confirm', data)
emit('update:visible', false)
})
}
</script>
<style scoped lang="scss">
.image-cropper-mask {
position: fixed;
top: 0;
left: 0;
z-index: 10001; // higher than hand-drawn-mask
width: 100vw;
height: 100%;
background-color: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
background: #f7faf9;
display: flex;
flex-direction: column;
.cropper-box {
flex: 1;
width: 100%;
height: calc(100% - 80px);
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAC6wGCdbt9AAAAIKFInOMAAAABYktHRACIBR1IAAAAC0lEQVR4nGNgwA0AAJoAAW7mSAAAAAAASUVORK5CYII=');
position: relative;
.loading-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: center;
align-items: center;
z-index: 10002;
}
:deep(.crop-point) {
width: 20px !important;
height: 20px !important;
background-color: #39f !important;
border-radius: 50%;
opacity: 0.8;
}
}
.tools {
height: 80px;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 0 20px;
background: #fff;
.mj-button {
margin: 0 4px;
}
}
}
</style>