검증게시판 기능 추가
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 클립보드·붙여넣기 이미지 → 업로드용 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;
|
||||
}
|
||||
Reference in New Issue
Block a user