184 lines
5.0 KiB
TypeScript
184 lines
5.0 KiB
TypeScript
/**
|
|
* 클립보드·붙여넣기 이미지 → 업로드용 File 정규화
|
|
*/
|
|
const ALLOWED_MIME = new Set([
|
|
'image/jpeg',
|
|
'image/png',
|
|
'image/gif',
|
|
'image/webp',
|
|
'image/bmp',
|
|
]);
|
|
|
|
/** 클립보드 항목에서 우선 선택할 MIME (macOS TIFF 등 제외) */
|
|
const CLIPBOARD_PREFERRED = [
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/webp',
|
|
'image/gif',
|
|
'image/bmp',
|
|
];
|
|
|
|
function normalizeMime(raw: string | undefined): string | null {
|
|
if (!raw) return null;
|
|
const base = raw.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
if (base === 'image/jpg' || base === 'image/pjpeg') {
|
|
return 'image/jpeg';
|
|
}
|
|
if (base === 'image/x-png') {
|
|
return 'image/png';
|
|
}
|
|
return ALLOWED_MIME.has(base) ? base : null;
|
|
}
|
|
|
|
function extForMime(mime: string): string {
|
|
switch (mime) {
|
|
case 'image/jpeg': return 'jpg';
|
|
case 'image/png': return 'png';
|
|
case 'image/gif': return 'gif';
|
|
case 'image/webp': return 'webp';
|
|
case 'image/bmp': return 'bmp';
|
|
default: return 'png';
|
|
}
|
|
}
|
|
|
|
function guessMimeFromName(name: string): string | undefined {
|
|
const lower = name.toLowerCase();
|
|
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
|
if (lower.endsWith('.png')) return 'image/png';
|
|
if (lower.endsWith('.gif')) return 'image/gif';
|
|
if (lower.endsWith('.webp')) return 'image/webp';
|
|
if (lower.endsWith('.bmp')) return 'image/bmp';
|
|
return undefined;
|
|
}
|
|
|
|
/** File MIME·이름 정규화 (클립보드·붙여넣기 대응) */
|
|
export function normalizeImageFile(file: File): File | null {
|
|
const mime = normalizeMime(file.type)
|
|
?? normalizeMime(guessMimeFromName(file.name))
|
|
?? 'image/png';
|
|
|
|
if (!ALLOWED_MIME.has(mime)) return null;
|
|
if (file.size === 0) return null;
|
|
|
|
const ext = extForMime(mime);
|
|
const name = file.name && !file.name.startsWith('blob')
|
|
? file.name
|
|
: `clipboard-${Date.now()}.${ext}`;
|
|
|
|
if (file.type === mime && file.name === name) return file;
|
|
return new File([file], name, { type: mime, lastModified: file.lastModified });
|
|
}
|
|
|
|
/** FileList / File[] → 허용 이미지만 */
|
|
export function pickImageFiles(list: FileList | File[]): File[] {
|
|
return Array.from(list)
|
|
.map(normalizeImageFile)
|
|
.filter((f): f is File => f != null);
|
|
}
|
|
|
|
/** 백엔드 제한(5MB) 초과 시 JPEG로 축소 */
|
|
export async function compressImageIfNeeded(
|
|
file: File,
|
|
maxBytes = 5 * 1024 * 1024,
|
|
): Promise<File> {
|
|
if (file.size <= maxBytes) return file;
|
|
|
|
let bitmap: ImageBitmap;
|
|
try {
|
|
bitmap = await createImageBitmap(file);
|
|
} catch {
|
|
throw new Error('IMAGE_READ_FAILED');
|
|
}
|
|
|
|
let w = bitmap.width;
|
|
let h = bitmap.height;
|
|
const maxDim = 2560;
|
|
if (w > maxDim || h > maxDim) {
|
|
const scale = Math.min(maxDim / w, maxDim / h);
|
|
w = Math.max(1, Math.round(w * scale));
|
|
h = Math.max(1, Math.round(h * scale));
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
bitmap.close();
|
|
throw new Error('IMAGE_READ_FAILED');
|
|
}
|
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
|
bitmap.close();
|
|
|
|
let quality = 0.9;
|
|
let blob: Blob | null = null;
|
|
for (let i = 0; i < 8; i++) {
|
|
blob = await new Promise<Blob | null>(resolve => {
|
|
canvas.toBlob(resolve, 'image/jpeg', quality);
|
|
});
|
|
if (blob && blob.size <= maxBytes) break;
|
|
quality -= 0.1;
|
|
}
|
|
|
|
if (!blob || blob.size > maxBytes) {
|
|
throw new Error('IMAGE_TOO_LARGE');
|
|
}
|
|
|
|
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
|
|
return new File([blob], `${base}.jpg`, { type: 'image/jpeg', lastModified: Date.now() });
|
|
}
|
|
|
|
/** 업로드 전 이미지 정규화·용량 조정 */
|
|
export async function prepareImagesForUpload(files: File[]): Promise<File[]> {
|
|
const normalized = pickImageFiles(files);
|
|
const out: File[] = [];
|
|
for (const f of normalized) {
|
|
out.push(await compressImageIfNeeded(f));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** ClipboardEvent / DataTransfer 에서 이미지 File 추출 */
|
|
export function filesFromDataTransfer(data: DataTransfer): File[] {
|
|
const out: File[] = [];
|
|
for (const item of Array.from(data.items)) {
|
|
if (item.kind !== 'file') continue;
|
|
const file = item.getAsFile();
|
|
if (!file) continue;
|
|
const normalized = normalizeImageFile(file);
|
|
if (normalized) out.push(normalized);
|
|
}
|
|
if (out.length === 0) {
|
|
return pickImageFiles(data.files);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** navigator.clipboard.read() 결과 → File[] */
|
|
export async function filesFromClipboardRead(): Promise<File[]> {
|
|
if (!navigator.clipboard?.read) {
|
|
throw new Error('CLIPBOARD_UNSUPPORTED');
|
|
}
|
|
|
|
const items = await navigator.clipboard.read();
|
|
const files: File[] = [];
|
|
|
|
for (const item of items) {
|
|
const type = CLIPBOARD_PREFERRED.find(t => item.types.includes(t));
|
|
if (!type) continue;
|
|
|
|
const blob = await item.getType(type);
|
|
if (!blob || blob.size === 0) continue;
|
|
|
|
const mime = normalizeMime(blob.type) ?? type;
|
|
const ext = extForMime(mime);
|
|
files.push(new File(
|
|
[blob],
|
|
`clipboard-${Date.now()}.${ext}`,
|
|
{ type: mime },
|
|
));
|
|
}
|
|
|
|
return files;
|
|
}
|