feat: implement client-side image compression with Canvas API

- Add imageResize utility using native Canvas API
- Compress images to max 1024x1024px before upload
- Support contain mode for proportional scaling
- Integrate compression into file upload, drag-drop, and URL loading flows
- Optimize memory management with proper URL cleanup
- Skip compression for images already below target size
This commit is contained in:
jingrow 2025-12-21 18:34:06 +08:00
parent c3d9263aac
commit bbf4527a12
2 changed files with 227 additions and 16 deletions

View File

@ -0,0 +1,184 @@
/**
* - 使 Canvas API
* 1024x1024px
*/
export interface ResizeOptions {
maxWidth?: number
maxHeight?: number
quality?: number
outputFormat?: 'image/jpeg' | 'image/png' | 'image/webp'
mode?: 'contain' | 'cover' | 'crop' // contain: 等比缩放, cover: 填充裁剪, crop: 居中裁剪
}
const DEFAULT_OPTIONS: Required<ResizeOptions> = {
maxWidth: 1024,
maxHeight: 1024,
quality: 0.92,
outputFormat: 'image/jpeg',
mode: 'contain'
}
/**
* 使 Canvas API
* @param file
* @param options
* @returns Blob
*/
export async function resizeImageWithCanvas(
file: File,
options: ResizeOptions = {}
): Promise<Blob> {
const opts = { ...DEFAULT_OPTIONS, ...options }
return new Promise((resolve, reject) => {
const img = new Image()
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('Canvas context not available'))
return
}
const objectUrl = URL.createObjectURL(file)
img.onload = () => {
try {
let { width, height } = img
// 如果图片尺寸已经小于等于目标尺寸,直接返回原图
if (width <= opts.maxWidth && height <= opts.maxHeight) {
URL.revokeObjectURL(objectUrl)
// 直接读取原文件为 blob
file.arrayBuffer().then(buffer => {
resolve(new Blob([buffer], { type: file.type }))
}).catch(reject)
return
}
// 计算目标尺寸
let targetWidth = width
let targetHeight = height
let sourceX = 0
let sourceY = 0
let sourceWidth = width
let sourceHeight = height
if (opts.mode === 'contain') {
// 等比缩放,保持宽高比
const scale = Math.min(
opts.maxWidth / width,
opts.maxHeight / height,
1 // 不放大
)
targetWidth = Math.round(width * scale)
targetHeight = Math.round(height * scale)
} else if (opts.mode === 'cover') {
// 填充模式:裁剪以填满目标尺寸
const scale = Math.max(
opts.maxWidth / width,
opts.maxHeight / height
)
targetWidth = opts.maxWidth
targetHeight = opts.maxHeight
sourceWidth = Math.round(opts.maxWidth / scale)
sourceHeight = Math.round(opts.maxHeight / scale)
sourceX = Math.round((width - sourceWidth) / 2)
sourceY = Math.round((height - sourceHeight) / 2)
} else if (opts.mode === 'crop') {
// 居中裁剪到目标尺寸
const scale = Math.max(
opts.maxWidth / width,
opts.maxHeight / height
)
const scaledWidth = width * scale
const scaledHeight = height * scale
targetWidth = opts.maxWidth
targetHeight = opts.maxHeight
sourceWidth = width
sourceHeight = height
// 计算裁剪区域
if (scaledWidth > opts.maxWidth) {
sourceX = Math.round((scaledWidth - opts.maxWidth) / 2 / scale)
sourceWidth = Math.round(opts.maxWidth / scale)
}
if (scaledHeight > opts.maxHeight) {
sourceY = Math.round((scaledHeight - opts.maxHeight) / 2 / scale)
sourceHeight = Math.round(opts.maxHeight / scale)
}
}
// 设置 canvas 尺寸
canvas.width = targetWidth
canvas.height = targetHeight
// 设置图片质量(仅对 JPEG 有效)
if (opts.outputFormat === 'image/jpeg') {
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, targetWidth, targetHeight)
}
// 绘制图片
ctx.drawImage(
img,
sourceX, sourceY, sourceWidth, sourceHeight,
0, 0, targetWidth, targetHeight
)
// 转换为 Blob
canvas.toBlob(
(blob) => {
// 清理对象 URL
URL.revokeObjectURL(objectUrl)
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob'))
}
},
opts.outputFormat,
opts.quality
)
} catch (error) {
URL.revokeObjectURL(objectUrl)
reject(error)
}
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
reject(new Error('Failed to load image'))
}
// 加载图片
img.src = objectUrl
})
}
/**
* File
*/
export async function compressImageFile(
file: File,
options: ResizeOptions = {}
): Promise<File> {
const blob = await resizeImageWithCanvas(file, options)
const opts = { ...DEFAULT_OPTIONS, ...options }
// 确定文件扩展名
let ext = 'jpg'
if (opts.outputFormat === 'image/png') {
ext = 'png'
} else if (opts.outputFormat === 'image/webp') {
ext = 'webp'
}
// 创建新的 File 对象,保持原始文件名但更新扩展名
const fileName = file.name.replace(/\.[^.]+$/, '') + '.' + ext
return new File([blob], fileName, {
type: opts.outputFormat,
lastModified: Date.now()
})
}

View File

@ -7,6 +7,7 @@ import { t } from '@/shared/i18n'
import { useAuthStore } from '@/shared/stores/auth' import { useAuthStore } from '@/shared/stores/auth'
import { signupApi } from '@/shared/api/auth' import { signupApi } from '@/shared/api/auth'
import UserMenu from '@/shared/components/UserMenu.vue' import UserMenu from '@/shared/components/UserMenu.vue'
import { compressImageFile } from '@/shared/utils/imageResize'
import axios from 'axios' import axios from 'axios'
const message = useMessage() const message = useMessage()
@ -285,7 +286,7 @@ const handleFileSelect = (e: Event) => {
} }
} }
const processFile = (file: File) => { const processFile = async (file: File) => {
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
if (!validTypes.includes(file.type)) { if (!validTypes.includes(file.type)) {
message.warning('不支持的图片格式,请使用 JPG、PNG 或 WebP') message.warning('不支持的图片格式,请使用 JPG、PNG 或 WebP')
@ -298,17 +299,29 @@ const processFile = (file: File) => {
return return
} }
uploadedImage.value = file try {
resultImage.value = '' const compressedFile = await compressImageFile(file, {
splitPosition.value = 0 maxWidth: 1024,
currentHistoryIndex.value = -1 maxHeight: 1024,
quality: 0.92,
const reader = new FileReader() mode: 'contain'
reader.onload = (e) => { })
uploadedImageUrl.value = e.target?.result as string
handleRemoveBackground() uploadedImage.value = compressedFile
resultImage.value = ''
splitPosition.value = 0
currentHistoryIndex.value = -1
const reader = new FileReader()
reader.onload = (e) => {
uploadedImageUrl.value = e.target?.result as string
handleRemoveBackground()
}
reader.readAsDataURL(compressedFile)
} catch (error) {
console.error('图片压缩失败:', error)
message.error('图片处理失败,请重试')
} }
reader.readAsDataURL(file)
} }
const handleRemoveBackground = async () => { const handleRemoveBackground = async () => {
@ -621,12 +634,26 @@ const handleUrlSubmit = async () => {
return return
} }
const file = new File([blob], 'image-from-url', { type: blob.type }) const originalFile = new File([blob], 'image-from-url', { type: blob.type })
uploadedImage.value = file
uploadedImageUrl.value = URL.createObjectURL(blob) // 1024x1024
try {
await handleRemoveBackground() const compressedFile = await compressImageFile(originalFile, {
maxWidth: 1024,
maxHeight: 1024,
quality: 0.92,
mode: 'contain' //
})
uploadedImage.value = compressedFile
uploadedImageUrl.value = URL.createObjectURL(compressedFile)
await handleRemoveBackground()
} catch (error) {
console.error('图片压缩失败:', error)
message.error('图片处理失败,请重试')
processing.value = false
}
} catch (error: any) { } catch (error: any) {
console.error('加载图片URL失败:', error) console.error('加载图片URL失败:', error)
let errorMessage = '加载图片URL失败' let errorMessage = '加载图片URL失败'