feat(ui): modals close only via Esc/X/cancel; standard 3:1 form cover processing

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 15:23:14 +03:30
co-authored by Cursor
parent f5b6a47f3c
commit 2f3646d085
19 changed files with 366 additions and 117 deletions
+72
View File
@@ -0,0 +1,72 @@
/** اندازهٔ استاندارد کاور فرم (نسبت ۳:۱) */
export const COVER_WIDTH = 1200;
export const COVER_HEIGHT = 400;
const MAX_INPUT_BYTES = 15_000_000;
/** تصاویر عریض‌تر از این نسبت بنر حساب می‌شوند و برش می‌خورند؛ بقیه لوگو هستند و کامل جا می‌گیرند */
const BANNER_MIN_RATIO = 2;
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('تصویر قابل خواندن نیست.'));
};
img.src = url;
});
}
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);
});
}
/**
* تصویر را به کاور ۱۲۰۰×۴۰۰ تبدیل می‌کند:
* بنرهای عریض از وسط برش می‌خورند (JPEG)،
* لوگوها و تصاویر مربعی/عمودی بدون برش وسط کادر با پس‌زمینهٔ شفاف قرار می‌گیرند (PNG) تا رنگ فرم پشتشان دیده شود.
*/
export async function prepareFormCover(file: File): Promise<File> {
if (!file.type.startsWith('image/')) throw new Error('فقط فایل تصویری (PNG، JPG، WEBP، SVG) مجاز است.');
if (file.size > MAX_INPUT_BYTES) throw new Error('حجم تصویر بیش از ۱۵ مگابایت است.');
const img = await loadImage(file);
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
if (!w || !h) throw new Error('ابعاد تصویر نامعتبر است.');
const canvas = document.createElement('canvas');
canvas.width = COVER_WIDTH;
canvas.height = COVER_HEIGHT;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('مرورگر از پردازش تصویر پشتیبانی نمی‌کند.');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const ratio = w / h;
if (ratio >= BANNER_MIN_RATIO) {
const target = COVER_WIDTH / COVER_HEIGHT;
let sw = w;
let sh = h;
if (ratio > target) sw = h * target;
else sh = w / target;
ctx.drawImage(img, (w - sw) / 2, (h - sh) / 2, sw, sh, 0, 0, COVER_WIDTH, COVER_HEIGHT);
const blob = await toBlob(canvas, 'image/jpeg', 0.88);
return new File([blob], 'cover.jpg', { type: 'image/jpeg' });
}
const boxW = COVER_WIDTH * 0.6;
const boxH = COVER_HEIGHT * 0.7;
const scale = Math.min(boxW / w, boxH / h, 3);
const dw = w * scale;
const dh = h * scale;
ctx.drawImage(img, (COVER_WIDTH - dw) / 2, (COVER_HEIGHT - dh) / 2, dw, dh);
const blob = await toBlob(canvas, 'image/png');
return new File([blob], 'cover.png', { type: 'image/png' });
}
+49
View File
@@ -0,0 +1,49 @@
import { onUnmounted, watch, type WatchSource } from 'vue';
type Entry = { id: number; close: () => void };
const stack: Entry[] = [];
let seq = 0;
let listening = false;
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape' || !stack.length || e.isComposing) return;
e.preventDefault();
e.stopPropagation();
stack[stack.length - 1]!.close();
}
function sync() {
if (stack.length && !listening) {
window.addEventListener('keydown', onKeydown, true);
listening = true;
} else if (!stack.length && listening) {
window.removeEventListener('keydown', onKeydown, true);
listening = false;
}
}
/**
* مودال/شیت را در پشتهٔ Esc ثبت می‌کند؛ Esc فقط بالاترین مودال باز را می‌بندد.
* بستن با کلیک بیرون عمداً پشتیبانی نمی‌شود تا داده‌های فرم از دست نرود.
*/
export function useEscClose(open: WatchSource<unknown>, close: () => void) {
const id = ++seq;
const remove = () => {
const i = stack.findIndex((e) => e.id === id);
if (i >= 0) stack.splice(i, 1);
sync();
};
watch(
open,
(isOpen) => {
remove();
if (isOpen) {
stack.push({ id, close });
sync();
}
},
{ immediate: true },
);
onUnmounted(remove);
}