185 lines
5.2 KiB
TypeScript
185 lines
5.2 KiB
TypeScript
/**
|
||
* 图片压缩工具 - 使用原生 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()
|
||
})
|
||
}
|
||
|