feat(lead-forms): real file upload under 200KB and Jalali date
Public form file fields compress images or reject oversized files; dates use the full Jalali picker.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/** سقف حجم فایل فیلد فرم عمومی پس از فشردهسازی */
|
||||
export const FORM_FILE_MAX_BYTES = 200 * 1024;
|
||||
|
||||
export class FormFileTooLargeError extends Error {
|
||||
constructor(message = 'حجم فایل زیاد است') {
|
||||
super(message);
|
||||
this.name = 'FormFileTooLargeError';
|
||||
}
|
||||
}
|
||||
|
||||
function toBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('فشردهسازی تصویر ناموفق بود.'))),
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(new Error('خواندن فایل ناموفق بود.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function compressImageUnder(file: File, maxBytes: number): Promise<File> {
|
||||
if (file.size <= maxBytes) return file;
|
||||
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
} catch {
|
||||
throw new FormFileTooLargeError();
|
||||
}
|
||||
|
||||
let edge = Math.max(bitmap.width, bitmap.height);
|
||||
edge = Math.min(edge, 1600);
|
||||
let quality = 0.85;
|
||||
let best: File | null = null;
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 14; attempt += 1) {
|
||||
const ratio = Math.min(edge / bitmap.width, edge / bitmap.height, 1);
|
||||
const width = Math.max(1, Math.round(bitmap.width * ratio));
|
||||
const height = Math.max(1, Math.round(bitmap.height * ratio));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new FormFileTooLargeError();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
|
||||
let blob: Blob;
|
||||
try {
|
||||
blob = await toBlob(canvas, 'image/jpeg', quality);
|
||||
} catch {
|
||||
blob = await toBlob(canvas, 'image/webp', quality);
|
||||
}
|
||||
|
||||
const base = file.name.replace(/\.[^/.]+$/, '') || 'file';
|
||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||||
const out = new File([blob], `${base}.${ext}`, {
|
||||
type: blob.type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
if (!best || out.size < best.size) best = out;
|
||||
if (out.size <= maxBytes) return out;
|
||||
|
||||
if (quality > 0.45) {
|
||||
quality = Math.max(0.4, quality - 0.1);
|
||||
} else {
|
||||
quality = 0.72;
|
||||
edge = Math.round(edge * 0.72);
|
||||
if (edge < 320) break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
|
||||
if (best && best.size <= maxBytes) return best;
|
||||
throw new FormFileTooLargeError();
|
||||
}
|
||||
|
||||
/**
|
||||
* فایل را تا سقف ۲۰۰ کیلوبایت فشرده میکند (تصاویر با کاهش کیفیت/ابعاد).
|
||||
* اگر ممکن نباشد، خطای «حجم فایل زیاد است» میدهد.
|
||||
*/
|
||||
export async function prepareFormFieldFile(file: File): Promise<{
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
dataUrl: string;
|
||||
}> {
|
||||
let prepared = file;
|
||||
if (file.type.startsWith('image/')) {
|
||||
prepared = await compressImageUnder(file, FORM_FILE_MAX_BYTES);
|
||||
} else if (file.size > FORM_FILE_MAX_BYTES) {
|
||||
throw new FormFileTooLargeError();
|
||||
}
|
||||
|
||||
if (prepared.size > FORM_FILE_MAX_BYTES) {
|
||||
throw new FormFileTooLargeError();
|
||||
}
|
||||
|
||||
const dataUrl = await fileToDataUrl(prepared);
|
||||
return {
|
||||
name: prepared.name || file.name || 'file',
|
||||
mime: prepared.type || file.type || 'application/octet-stream',
|
||||
size: prepared.size,
|
||||
dataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/** مقدار ذخیرهشده در پاسخ فرم: نام + data URL */
|
||||
export function encodeFormFileValue(meta: {
|
||||
name: string;
|
||||
mime: string;
|
||||
dataUrl: string;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
name: meta.name,
|
||||
mime: meta.mime,
|
||||
dataUrl: meta.dataUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function formFileDisplayName(raw: string | undefined | null): string {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: string };
|
||||
if (parsed?.name) return parsed.name;
|
||||
} catch {
|
||||
/* plain string */
|
||||
}
|
||||
if (raw.startsWith('data:')) return 'فایل پیوست';
|
||||
return raw.length > 48 ? `${raw.slice(0, 48)}…` : raw;
|
||||
}
|
||||
Reference in New Issue
Block a user