126 lines
3.9 KiB
TypeScript
126 lines
3.9 KiB
TypeScript
import type React from 'react'
|
||
|
||
const FALLBACK_IMAGE_EXTENSIONS = new Set([
|
||
'avif',
|
||
'bmp',
|
||
'gif',
|
||
'jpeg',
|
||
'jpg',
|
||
'png',
|
||
'webp',
|
||
])
|
||
|
||
export interface ImageFileValidationOptions {
|
||
maxBytes?: number
|
||
maxCount?: number
|
||
existingCount?: number
|
||
/**
|
||
* 可选的业务格式白名单。浏览器的 image/* MIME 范围比实际处理能力更宽,
|
||
* 需要在特定上传链路中按扩展名提前拦截不兼容格式。
|
||
*/
|
||
allowedExtensions?: readonly string[]
|
||
}
|
||
|
||
export interface ImageFileValidationResult {
|
||
acceptedFiles: File[]
|
||
errors: string[]
|
||
}
|
||
|
||
export function hasDragLeftDropZone(event: React.DragEvent<Element>): boolean {
|
||
const nextTarget = event.relatedTarget as Node | null
|
||
if (nextTarget && nextTarget !== event.currentTarget) {
|
||
return !event.currentTarget.contains(nextTarget)
|
||
}
|
||
|
||
const rect = event.currentTarget.getBoundingClientRect()
|
||
const { clientX, clientY } = event
|
||
return clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom
|
||
}
|
||
|
||
export function prepareFileDrag(event: React.DragEvent<Element>, disabled = false): void {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
event.dataTransfer.dropEffect = disabled ? 'none' : 'copy'
|
||
}
|
||
|
||
/**
|
||
* 让拖拽上传区域也能通过键盘打开文件选择器,避免只能用鼠标点击。
|
||
*/
|
||
export function handleDropZoneKeyDown(
|
||
event: React.KeyboardEvent<HTMLElement>,
|
||
openFilePicker: () => void,
|
||
): void {
|
||
if (event.target !== event.currentTarget) return
|
||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||
event.preventDefault()
|
||
openFilePicker()
|
||
}
|
||
|
||
export function isSupportedImageFile(file: File): boolean {
|
||
if (file.type) {
|
||
return file.type.startsWith('image/')
|
||
}
|
||
|
||
const extension = getFileExtension(file)
|
||
return FALLBACK_IMAGE_EXTENSIONS.has(extension)
|
||
}
|
||
|
||
export function getFileExtension(file: File): string {
|
||
return file.name.split('.').pop()?.toLowerCase() || ''
|
||
}
|
||
|
||
export function hasAllowedFileExtension(file: File, allowedExtensions: readonly string[]): boolean {
|
||
const extension = getFileExtension(file)
|
||
return allowedExtensions.some((allowedExtension) => allowedExtension.replace(/^\./, '').toLowerCase() === extension)
|
||
}
|
||
|
||
export function validateImageFiles(
|
||
files: FileList | File[],
|
||
options: ImageFileValidationOptions = {},
|
||
): ImageFileValidationResult {
|
||
const { maxBytes, maxCount, existingCount = 0, allowedExtensions } = options
|
||
const unsupportedNames: string[] = []
|
||
const oversizedNames: string[] = []
|
||
const validFiles: File[] = []
|
||
|
||
for (const file of Array.from(files)) {
|
||
const isValidImage = allowedExtensions
|
||
? hasAllowedFileExtension(file, allowedExtensions)
|
||
: isSupportedImageFile(file)
|
||
if (!isValidImage) {
|
||
unsupportedNames.push(file.name)
|
||
continue
|
||
}
|
||
|
||
if (maxBytes !== undefined && file.size > maxBytes) {
|
||
oversizedNames.push(file.name)
|
||
continue
|
||
}
|
||
|
||
validFiles.push(file)
|
||
}
|
||
|
||
const remainingCount = maxCount === undefined
|
||
? validFiles.length
|
||
: Math.max(0, maxCount - existingCount)
|
||
const acceptedFiles = validFiles.slice(0, remainingCount)
|
||
const ignoredByCount = Math.max(0, validFiles.length - acceptedFiles.length)
|
||
const errors: string[] = []
|
||
|
||
if (unsupportedNames.length > 0) {
|
||
const supportedLabel = allowedExtensions?.length
|
||
? `,请上传 ${allowedExtensions.map((extension) => extension.replace(/^\./, '').toUpperCase()).join('、')} 格式`
|
||
: ''
|
||
errors.push(`以下文件不是支持的图片格式:${unsupportedNames.join('、')}${supportedLabel}`)
|
||
}
|
||
if (oversizedNames.length > 0 && maxBytes !== undefined) {
|
||
const maxMegabytes = Math.round(maxBytes / 1024 / 1024)
|
||
errors.push(`以下图片超过 ${maxMegabytes}MB:${oversizedNames.join('、')}`)
|
||
}
|
||
if (ignoredByCount > 0 && maxCount !== undefined) {
|
||
errors.push(`最多只能选择 ${maxCount} 张图片,已忽略 ${ignoredByCount} 张`)
|
||
}
|
||
|
||
return { acceptedFiles, errors }
|
||
}
|