262 lines
7.0 KiB
Vue
262 lines
7.0 KiB
Vue
<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>
|