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:
@@ -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' });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user