You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

156 lines
4.1 KiB

<script setup lang="ts">
import { CircleStencil, Cropper, RectangleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
const props = defineProps<{
file: File | null
variant: 'avatar' | 'headerIcon'
}>()
const emit = defineEmits<{
confirm: [file: File]
}>()
const open = defineModel<boolean>('open', { default: false })
const imageSrc = ref<string | null>(null)
const cropperRef = ref<{ getResult: () => { canvas?: HTMLCanvasElement } } | null>(null)
const confirming = ref(false)
const title = computed(() =>
props.variant === 'avatar' ? '裁剪头像' : '裁剪公开主页顶栏图标',
)
const stencilComponent = computed(() =>
props.variant === 'avatar' ? CircleStencil : RectangleStencil,
)
const stencilProps = { aspectRatio: 1 }
const outputMaxSide = computed(() => (props.variant === 'avatar' ? 512 : 512))
watch(
() => ({ isOpen: open.value, f: props.file }),
({ isOpen, f }) => {
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
imageSrc.value = null
}
if (isOpen && f) {
imageSrc.value = URL.createObjectURL(f)
}
},
{ immediate: true },
)
onBeforeUnmount(() => {
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
}
})
function canvasToOutputFile(
source: HTMLCanvasElement,
mime: string,
maxSide: number,
filename: string,
): Promise<File> {
let w = source.width
let h = source.height
let canvas = source
if (w > maxSide || h > maxSide) {
const scale = maxSide / Math.max(w, h)
w = Math.round(w * scale)
h = Math.round(h * scale)
const c = document.createElement('canvas')
c.width = w
c.height = h
const ctx = c.getContext('2d')
if (!ctx) {
throw new Error('无法创建画布上下文')
}
ctx.drawImage(source, 0, 0, w, h)
canvas = c
}
const quality = mime === 'image/jpeg' ? 0.92 : mime === 'image/webp' ? 0.9 : undefined
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('导出图片失败'))
return
}
resolve(new File([blob], filename, { type: blob.type }))
},
mime,
quality,
)
})
}
function pickOutputMime(original: File | null): { mime: string; ext: string } {
const t = original?.type
if (t === 'image/png') {
return { mime: 'image/png', ext: 'png' }
}
if (t === 'image/webp') {
return { mime: 'image/webp', ext: 'webp' }
}
return { mime: 'image/jpeg', ext: 'jpg' }
}
async function onConfirm() {
const cropper = cropperRef.value
if (!cropper || !props.file) {
return
}
confirming.value = true
try {
const { canvas } = cropper.getResult()
if (!canvas) {
throw new Error('无法生成裁剪图')
}
const { mime, ext } = pickOutputMime(props.file)
const base = props.file.name.replace(/\.[^.]+$/, '') || 'image'
const out = await canvasToOutputFile(canvas, mime, outputMaxSide.value, `${base}-cropped.${ext}`)
emit('confirm', out)
open.value = false
} finally {
confirming.value = false
}
}
</script>
<template>
<UModal
v-model:open="open"
:title="title"
description="拖动、缩放图片以选取区域;确认后将上传裁剪后的图片。"
class="w-[calc(100vw-2rem)] sm:max-w-lg"
>
<template #body>
<div v-if="imageSrc" class="space-y-3">
<Cropper
ref="cropperRef"
class="min-h-[280px] h-[min(52vh,420px)] rounded-lg bg-elevated"
:src="imageSrc"
:stencil-component="stencilComponent"
:stencil-props="stencilProps"
/>
<p class="text-xs text-muted">
{{ variant === 'avatar' ? '头像将显示为圆形,建议把主体放在圆内。' : '顶栏图标为方形显示。' }}
</p>
</div>
</template>
<template #footer="{ close }">
<div class="flex justify-end gap-2">
<UButton type="button" variant="outline" color="neutral" @click="close">
取消
</UButton>
<UButton type="button" :loading="confirming" :disabled="!imageSrc" @click="onConfirm">
确认并上传
</UButton>
</div>
</template>
</UModal>
</template>