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
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>
|
|
|