mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(resize): add aspect-ratio proportion presets (#530)
Add a proportion chip row (Free, Original, 1:1, 4:3, 3:2, 16:9, 3:4, 9:16) to the Resize tool's Custom tab. Picking a ratio locks width and height so editing one recomputes the other, and prefills the largest box of that ratio that fits the source so it never upscales. Free stays the default, preserving existing behavior. Replaces the previously non-functional lock-aspect button. Frontend only, no backend or schema change; adds strings to all 21 locales.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { SOCIAL_MEDIA_PRESETS } from "@snapotter/shared";
|
||||
import { Download, Info, Link, Unlink } from "lucide-react";
|
||||
import { Download, Info } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { largestRatioBox, pairedDimension, RESIZE_RATIO_PRESETS } from "@/lib/aspect-ratio";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
@@ -33,13 +34,16 @@ export interface ResizeControlsProps {
|
||||
|
||||
export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { currentEntry } = useFileStore();
|
||||
const [tab, setTab] = useState<ResizeTab>("custom");
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
const [width, setWidth] = useState<string>("");
|
||||
const [height, setHeight] = useState<string>("");
|
||||
const [percentage, setPercentage] = useState<string>("50");
|
||||
const [fit, setFit] = useState<FitMode>("cover");
|
||||
const [lockAspect, setLockAspect] = useState(true);
|
||||
// "free" = independent width/height (default, unchanged behavior). "original" =
|
||||
// lock to the source image's ratio. Otherwise a RESIZE_RATIO_PRESETS id (e.g. "16:9").
|
||||
const [ratioId, setRatioId] = useState<string>("free");
|
||||
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
||||
const contentAware = tab === "content-aware";
|
||||
const [protectFaces, setProtectFaces] = useState(false);
|
||||
@@ -107,6 +111,59 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
||||
contentAware,
|
||||
]);
|
||||
|
||||
// Resolve a chip id to a numeric ratio (width / height), or null when it
|
||||
// shouldn't lock (Free, or Original before the source dimensions are known).
|
||||
const ratioValueFor = (id: string): number | null => {
|
||||
if (id === "free") return null;
|
||||
if (id === "original") {
|
||||
const w = currentEntry?.originalWidth;
|
||||
const h = currentEntry?.originalHeight;
|
||||
return w && h ? w / h : null;
|
||||
}
|
||||
return RESIZE_RATIO_PRESETS.find((p) => p.id === id)?.value ?? null;
|
||||
};
|
||||
|
||||
// Linking only applies on the Custom tab; the width/height inputs are shared
|
||||
// with the Content-Aware tab, which manages its dimensions independently.
|
||||
const handleWidthChange = (raw: string) => {
|
||||
setWidth(raw);
|
||||
if (tab !== "custom") return;
|
||||
const r = ratioValueFor(ratioId);
|
||||
const n = Number(raw);
|
||||
if (r && raw !== "" && Number.isFinite(n) && n > 0) {
|
||||
setHeight(String(pairedDimension(n, r, "width")));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeightChange = (raw: string) => {
|
||||
setHeight(raw);
|
||||
if (tab !== "custom") return;
|
||||
const r = ratioValueFor(ratioId);
|
||||
const n = Number(raw);
|
||||
if (r && raw !== "" && Number.isFinite(n) && n > 0) {
|
||||
setWidth(String(pairedDimension(n, r, "height")));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRatioSelect = (id: string) => {
|
||||
setRatioId(id);
|
||||
const r = ratioValueFor(id);
|
||||
if (!r) return;
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
if (width !== "" && Number.isFinite(w) && w > 0) {
|
||||
// Keep the width the user already has, snap height to the ratio.
|
||||
setHeight(String(pairedDimension(w, r, "width")));
|
||||
} else if (height !== "" && Number.isFinite(h) && h > 0) {
|
||||
setWidth(String(pairedDimension(h, r, "height")));
|
||||
} else if (currentEntry?.originalWidth && currentEntry?.originalHeight) {
|
||||
// Nothing typed yet: prefill the largest box of this ratio that fits.
|
||||
const box = largestRatioBox(currentEntry.originalWidth, currentEntry.originalHeight, r);
|
||||
setWidth(String(box.width));
|
||||
setHeight(String(box.height));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
||||
const key = `${preset.platform}-${preset.name}`;
|
||||
if (selectedPreset === key) {
|
||||
@@ -123,6 +180,12 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
||||
const tabClass = (t: ResizeTab) =>
|
||||
`flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
|
||||
|
||||
const ratioChips: { id: string; label: string }[] = [
|
||||
{ id: "free", label: t.toolSettings.resize.ratioFree },
|
||||
{ id: "original", label: t.toolSettings.resize.ratioOriginal },
|
||||
...RESIZE_RATIO_PRESETS.map((p) => ({ id: p.id, label: p.id })),
|
||||
];
|
||||
|
||||
const dimensionInputs = (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
@@ -133,20 +196,12 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
||||
id="resize-width"
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
onChange={(e) => handleWidthChange(e.target.value)}
|
||||
placeholder="Auto"
|
||||
disabled={squareMode && contentAware}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLockAspect(!lockAspect)}
|
||||
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
|
||||
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
>
|
||||
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings.resize.heightPx}
|
||||
@@ -155,7 +210,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
||||
id="resize-height"
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
onChange={(e) => handleHeightChange(e.target.value)}
|
||||
placeholder="Auto"
|
||||
disabled={squareMode && contentAware}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground disabled:opacity-50"
|
||||
@@ -242,6 +297,28 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
||||
<div className="space-y-3">
|
||||
{dimensionInputs}
|
||||
|
||||
{/* Aspect ratio */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t.toolSettings.resize.aspectRatio}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{ratioChips.map((chip) => (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
onClick={() => handleRatioSelect(chip.id)}
|
||||
aria-pressed={ratioId === chip.id}
|
||||
className={`px-2.5 py-1 rounded text-xs ${
|
||||
ratioId === chip.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fit mode */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t.toolSettings.resize.fitMode}</p>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Pure aspect-ratio math shared by the Resize tool's proportion controls.
|
||||
// A "ratio" here is always width / height (16:9 -> 16/9 -> 1.777...).
|
||||
// Kept free of React/DOM so it can be unit-tested in isolation.
|
||||
|
||||
// Matches the resize route's Sharp guardrail (apps/api/src/routes/tools/resize.ts).
|
||||
export const MAX_RESIZE_DIMENSION = 16383;
|
||||
|
||||
export interface RatioPreset {
|
||||
id: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
// Fixed proportions offered as one-tap chips. "Free" and "Original" are UI
|
||||
// modes handled by the component, not entries here.
|
||||
export const RESIZE_RATIO_PRESETS: RatioPreset[] = [
|
||||
{ id: "1:1", value: 1 },
|
||||
{ id: "4:3", value: 4 / 3 },
|
||||
{ id: "3:2", value: 3 / 2 },
|
||||
{ id: "16:9", value: 16 / 9 },
|
||||
{ id: "3:4", value: 3 / 4 },
|
||||
{ id: "9:16", value: 9 / 16 },
|
||||
];
|
||||
|
||||
// Round to a whole pixel and clamp into the [1, MAX] range the backend accepts.
|
||||
export function clampResizeDimension(value: number): number {
|
||||
return Math.min(MAX_RESIZE_DIMENSION, Math.max(1, Math.round(value)));
|
||||
}
|
||||
|
||||
// Given one edited dimension and a locked ratio, compute the paired dimension.
|
||||
// axis names which dimension `value` is: editing width yields a height, and
|
||||
// vice versa.
|
||||
export function pairedDimension(value: number, ratio: number, axis: "width" | "height"): number {
|
||||
const paired = axis === "width" ? value / ratio : value * ratio;
|
||||
return clampResizeDimension(paired);
|
||||
}
|
||||
|
||||
// Largest box of the given ratio that fits fully inside the source, so a chip
|
||||
// tap never upscales past the original image.
|
||||
export function largestRatioBox(
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
ratio: number,
|
||||
): { width: number; height: number } {
|
||||
if (sourceWidth / sourceHeight >= ratio) {
|
||||
// Source is wider than the target ratio: height is the limiting edge.
|
||||
return {
|
||||
width: clampResizeDimension(sourceHeight * ratio),
|
||||
height: clampResizeDimension(sourceHeight),
|
||||
};
|
||||
}
|
||||
// Source is taller/narrower: width is the limiting edge.
|
||||
return {
|
||||
width: clampResizeDimension(sourceWidth),
|
||||
height: clampResizeDimension(sourceWidth / ratio),
|
||||
};
|
||||
}
|
||||
@@ -1189,6 +1189,9 @@ export const ar: TranslationKeys = {
|
||||
scale: "مقياس",
|
||||
presets: "قوالب جاهزة",
|
||||
contentAware: "مراعاة المحتوى",
|
||||
aspectRatio: "نسبة العرض إلى الارتفاع",
|
||||
ratioFree: "حر",
|
||||
ratioOriginal: "الأصلي",
|
||||
widthPx: "العرض (px)",
|
||||
heightPx: "الارتفاع (px)",
|
||||
fitMode: "وضع الملاءمة",
|
||||
|
||||
@@ -1202,6 +1202,9 @@ export const de: TranslationKeys = {
|
||||
scale: "Skalierung",
|
||||
presets: "Vorlagen",
|
||||
contentAware: "Inhaltsabhängig",
|
||||
aspectRatio: "Seitenverhältnis",
|
||||
ratioFree: "Frei",
|
||||
ratioOriginal: "Original",
|
||||
widthPx: "Breite (px)",
|
||||
heightPx: "Höhe (px)",
|
||||
fitMode: "Anpassungsmodus",
|
||||
|
||||
@@ -1152,6 +1152,9 @@ export const en = {
|
||||
scale: "Scale",
|
||||
presets: "Presets",
|
||||
contentAware: "Content-Aware",
|
||||
aspectRatio: "Aspect Ratio",
|
||||
ratioFree: "Free",
|
||||
ratioOriginal: "Original",
|
||||
widthPx: "Width (px)",
|
||||
heightPx: "Height (px)",
|
||||
fitMode: "Fit Mode",
|
||||
|
||||
@@ -1186,6 +1186,9 @@ export const es: TranslationKeys = {
|
||||
scale: "Escala",
|
||||
presets: "Predefinidos",
|
||||
contentAware: "Consciente del contenido",
|
||||
aspectRatio: "Relación de aspecto",
|
||||
ratioFree: "Libre",
|
||||
ratioOriginal: "Original",
|
||||
widthPx: "Ancho (px)",
|
||||
heightPx: "Alto (px)",
|
||||
fitMode: "Modo de ajuste",
|
||||
|
||||
@@ -1208,6 +1208,9 @@ export const fr: TranslationKeys = {
|
||||
scale: "Échelle",
|
||||
presets: "Préréglages",
|
||||
contentAware: "Intelligent",
|
||||
aspectRatio: "Rapport d'aspect",
|
||||
ratioFree: "Libre",
|
||||
ratioOriginal: "Original",
|
||||
widthPx: "Largeur (px)",
|
||||
heightPx: "Hauteur (px)",
|
||||
fitMode: "Mode d'ajustement",
|
||||
|
||||
@@ -1021,6 +1021,9 @@ export const hi: TranslationKeys = {
|
||||
scale: "स्केल",
|
||||
presets: "प्रीसेट",
|
||||
contentAware: "कंटेंट-अवेयर",
|
||||
aspectRatio: "आस्पेक्ट अनुपात",
|
||||
ratioFree: "मुक्त",
|
||||
ratioOriginal: "मूल",
|
||||
widthPx: "चौड़ाई (px)",
|
||||
heightPx: "ऊंचाई (px)",
|
||||
fitMode: "फिट मोड",
|
||||
|
||||
@@ -1198,6 +1198,9 @@ export const id: TranslationKeys = {
|
||||
scale: "Skala",
|
||||
presets: "Preset",
|
||||
contentAware: "Sadar Konten",
|
||||
aspectRatio: "Rasio Aspek",
|
||||
ratioFree: "Bebas",
|
||||
ratioOriginal: "Asli",
|
||||
widthPx: "Lebar (px)",
|
||||
heightPx: "Tinggi (px)",
|
||||
fitMode: "Mode Pas",
|
||||
|
||||
@@ -1202,6 +1202,9 @@ export const it: TranslationKeys = {
|
||||
scale: "Scala",
|
||||
presets: "Preset",
|
||||
contentAware: "Consapevole del contenuto",
|
||||
aspectRatio: "Proporzioni",
|
||||
ratioFree: "Libero",
|
||||
ratioOriginal: "Originale",
|
||||
widthPx: "Larghezza (px)",
|
||||
heightPx: "Altezza (px)",
|
||||
fitMode: "Modalità di adattamento",
|
||||
|
||||
@@ -1160,6 +1160,9 @@ export const ja: TranslationKeys = {
|
||||
scale: "スケール",
|
||||
presets: "プリセット",
|
||||
contentAware: "コンテンツ認識",
|
||||
aspectRatio: "アスペクト比",
|
||||
ratioFree: "自由",
|
||||
ratioOriginal: "元の比率",
|
||||
widthPx: "幅(px)",
|
||||
heightPx: "高さ(px)",
|
||||
fitMode: "フィットモード",
|
||||
|
||||
@@ -1144,6 +1144,9 @@ export const ko: TranslationKeys = {
|
||||
scale: "스케일",
|
||||
presets: "프리셋",
|
||||
contentAware: "콘텐츠 인식",
|
||||
aspectRatio: "종횡비",
|
||||
ratioFree: "자유",
|
||||
ratioOriginal: "원본",
|
||||
widthPx: "너비 (px)",
|
||||
heightPx: "높이 (px)",
|
||||
fitMode: "맞춤 모드",
|
||||
|
||||
@@ -1202,6 +1202,9 @@ export const nl: TranslationKeys = {
|
||||
scale: "Schaal",
|
||||
presets: "Voorinstellingen",
|
||||
contentAware: "Inhoudsbewust",
|
||||
aspectRatio: "Beeldverhouding",
|
||||
ratioFree: "Vrij",
|
||||
ratioOriginal: "Origineel",
|
||||
widthPx: "Breedte (px)",
|
||||
heightPx: "Hoogte (px)",
|
||||
fitMode: "Pasmodus",
|
||||
|
||||
@@ -1200,6 +1200,9 @@ export const pl: TranslationKeys = {
|
||||
scale: "Skala",
|
||||
presets: "Szablony",
|
||||
contentAware: "Z uwzględnieniem treści",
|
||||
aspectRatio: "Proporcje",
|
||||
ratioFree: "Dowolny",
|
||||
ratioOriginal: "Oryginał",
|
||||
widthPx: "Szerokość (px)",
|
||||
heightPx: "Wysokość (px)",
|
||||
fitMode: "Tryb dopasowania",
|
||||
|
||||
@@ -1200,6 +1200,9 @@ export const ptBR: TranslationKeys = {
|
||||
scale: "Escala",
|
||||
presets: "Predefinições",
|
||||
contentAware: "Consciente do conteúdo",
|
||||
aspectRatio: "Proporção",
|
||||
ratioFree: "Livre",
|
||||
ratioOriginal: "Original",
|
||||
widthPx: "Largura (px)",
|
||||
heightPx: "Altura (px)",
|
||||
fitMode: "Modo de ajuste",
|
||||
|
||||
@@ -1200,6 +1200,9 @@ export const ru: TranslationKeys = {
|
||||
scale: "Масштаб",
|
||||
presets: "Шаблоны",
|
||||
contentAware: "Контентно-зависимый",
|
||||
aspectRatio: "Соотношение сторон",
|
||||
ratioFree: "Свободно",
|
||||
ratioOriginal: "Исходный",
|
||||
widthPx: "Ширина (px)",
|
||||
heightPx: "Высота (px)",
|
||||
fitMode: "Режим вписывания",
|
||||
|
||||
@@ -1197,6 +1197,9 @@ export const sv: TranslationKeys = {
|
||||
scale: "Skala",
|
||||
presets: "Mallar",
|
||||
contentAware: "Innehållsmedveten",
|
||||
aspectRatio: "Bildförhållande",
|
||||
ratioFree: "Fri",
|
||||
ratioOriginal: "Original",
|
||||
widthPx: "Bredd (px)",
|
||||
heightPx: "Höjd (px)",
|
||||
fitMode: "Passningsmetod",
|
||||
|
||||
@@ -1183,6 +1183,9 @@ export const th: TranslationKeys = {
|
||||
scale: "สเกล",
|
||||
presets: "พรีเซ็ต",
|
||||
contentAware: "รับรู้เนื้อหา",
|
||||
aspectRatio: "อัตราส่วนภาพ",
|
||||
ratioFree: "อิสระ",
|
||||
ratioOriginal: "ต้นฉบับ",
|
||||
widthPx: "ความกว้าง (px)",
|
||||
heightPx: "ความสูง (px)",
|
||||
fitMode: "โหมดพอดี",
|
||||
|
||||
@@ -1200,6 +1200,9 @@ export const tr: TranslationKeys = {
|
||||
scale: "Ölçek",
|
||||
presets: "Şablonlar",
|
||||
contentAware: "İçerik Duyarlı",
|
||||
aspectRatio: "En Boy Oranı",
|
||||
ratioFree: "Serbest",
|
||||
ratioOriginal: "Orijinal",
|
||||
widthPx: "Genişlik (px)",
|
||||
heightPx: "Yükseklik (px)",
|
||||
fitMode: "Sığdırma Modu",
|
||||
|
||||
@@ -1200,6 +1200,9 @@ export const uk: TranslationKeys = {
|
||||
scale: "Масштаб",
|
||||
presets: "Шаблони",
|
||||
contentAware: "Контентно-залежний",
|
||||
aspectRatio: "Співвідношення сторін",
|
||||
ratioFree: "Вільно",
|
||||
ratioOriginal: "Оригінал",
|
||||
widthPx: "Ширина (px)",
|
||||
heightPx: "Висота (px)",
|
||||
fitMode: "Режим вписування",
|
||||
|
||||
@@ -1201,6 +1201,9 @@ export const vi: TranslationKeys = {
|
||||
scale: "Tỷ lệ",
|
||||
presets: "Mẫu có sẵn",
|
||||
contentAware: "Nhận diện nội dung",
|
||||
aspectRatio: "Tỷ lệ khung hình",
|
||||
ratioFree: "Tự do",
|
||||
ratioOriginal: "Gốc",
|
||||
widthPx: "Chiều rộng (px)",
|
||||
heightPx: "Chiều cao (px)",
|
||||
fitMode: "Chế độ vừa vặn",
|
||||
|
||||
@@ -971,6 +971,9 @@ export const zhCN: TranslationKeys = {
|
||||
scale: "缩放",
|
||||
presets: "预设",
|
||||
contentAware: "内容感知",
|
||||
aspectRatio: "宽高比",
|
||||
ratioFree: "自由",
|
||||
ratioOriginal: "原始",
|
||||
widthPx: "宽度(px)",
|
||||
heightPx: "高度(px)",
|
||||
fitMode: "适应模式",
|
||||
|
||||
@@ -971,6 +971,9 @@ export const zhTW: TranslationKeys = {
|
||||
scale: "縮放",
|
||||
presets: "預設",
|
||||
contentAware: "內容感知",
|
||||
aspectRatio: "長寬比",
|
||||
ratioFree: "自由",
|
||||
ratioOriginal: "原始",
|
||||
widthPx: "寬度(px)",
|
||||
heightPx: "高度(px)",
|
||||
fitMode: "適應模式",
|
||||
|
||||
@@ -823,18 +823,25 @@ test.describe("GUI Essential Tools", () => {
|
||||
// RESIZE: LINKED ASPECT RATIO AUTO-UPDATE
|
||||
// ========================================================================
|
||||
test.describe("Resize Aspect Ratio Linked Fields", () => {
|
||||
// The resize component stores lockAspect as UI state but does not
|
||||
// auto-compute the paired dimension on input change. The width/height
|
||||
// onChange handlers call setWidth/setHeight independently. Linked
|
||||
// auto-update is not implemented in the current component, so these
|
||||
// tests cannot pass until that feature is added.
|
||||
test.skip("width auto-updates height when aspect ratio locked", async ({
|
||||
loggedInPage: _page,
|
||||
}) => {});
|
||||
// Selecting a ratio chip on the Custom tab locks the width/height fields:
|
||||
// editing one recomputes the other. 16:9 is fixture-independent (1600 -> 900).
|
||||
test("width auto-updates height when a ratio is locked", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/image/resize");
|
||||
await uploadTestImage(page);
|
||||
|
||||
test.skip("height auto-updates width when aspect ratio locked", async ({
|
||||
loggedInPage: _page,
|
||||
}) => {});
|
||||
await page.getByRole("button", { name: "16:9", exact: true }).click();
|
||||
await page.locator("#resize-width").fill("1600");
|
||||
await expect(page.locator("#resize-height")).toHaveValue("900");
|
||||
});
|
||||
|
||||
test("height auto-updates width when a ratio is locked", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/image/resize");
|
||||
await uploadTestImage(page);
|
||||
|
||||
await page.getByRole("button", { name: "16:9", exact: true }).click();
|
||||
await page.locator("#resize-height").fill("900");
|
||||
await expect(page.locator("#resize-width")).toHaveValue("1600");
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampResizeDimension,
|
||||
largestRatioBox,
|
||||
MAX_RESIZE_DIMENSION,
|
||||
pairedDimension,
|
||||
RESIZE_RATIO_PRESETS,
|
||||
} from "@/lib/aspect-ratio";
|
||||
|
||||
describe("pairedDimension", () => {
|
||||
it("computes height from width for 16:9", () => {
|
||||
expect(pairedDimension(1600, 16 / 9, "width")).toBe(900);
|
||||
});
|
||||
|
||||
it("computes width from height for 16:9", () => {
|
||||
expect(pairedDimension(900, 16 / 9, "height")).toBe(1600);
|
||||
});
|
||||
|
||||
it("keeps a 1:1 square equal on both axes", () => {
|
||||
expect(pairedDimension(200, 1, "width")).toBe(200);
|
||||
expect(pairedDimension(200, 1, "height")).toBe(200);
|
||||
});
|
||||
|
||||
it("rounds to the nearest whole pixel", () => {
|
||||
// width 100 at 3:2 (1.5) -> height 66.67 -> 67
|
||||
expect(pairedDimension(100, 3 / 2, "width")).toBe(67);
|
||||
});
|
||||
|
||||
it("clamps the result to at least 1px", () => {
|
||||
// 1 / (16/9) = 0.5625 -> rounds to 1
|
||||
expect(pairedDimension(1, 16 / 9, "width")).toBe(1);
|
||||
});
|
||||
|
||||
it("never exceeds the max dimension", () => {
|
||||
expect(pairedDimension(MAX_RESIZE_DIMENSION, 9 / 16, "width")).toBeLessThanOrEqual(
|
||||
MAX_RESIZE_DIMENSION,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("largestRatioBox", () => {
|
||||
it("fits a 16:9 box inside a 4:3 landscape source, constrained by width", () => {
|
||||
// 4000x3000 (1.333) is narrower than 16:9 (1.778) -> width wins
|
||||
expect(largestRatioBox(4000, 3000, 16 / 9)).toEqual({ width: 4000, height: 2250 });
|
||||
});
|
||||
|
||||
it("fits a 16:9 box inside a very wide source, constrained by height", () => {
|
||||
// 3000x1000 (3.0) is wider than 16:9 -> height wins
|
||||
expect(largestRatioBox(3000, 1000, 16 / 9)).toEqual({ width: 1778, height: 1000 });
|
||||
});
|
||||
|
||||
it("returns the source itself when the ratio already matches", () => {
|
||||
expect(largestRatioBox(1920, 1080, 16 / 9)).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it("never upscales beyond the source dimensions", () => {
|
||||
const box = largestRatioBox(1080, 1920, 16 / 9); // portrait source -> 16:9
|
||||
expect(box.width).toBeLessThanOrEqual(1080);
|
||||
expect(box.height).toBeLessThanOrEqual(1920);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampResizeDimension", () => {
|
||||
it("rounds fractional pixels", () => {
|
||||
expect(clampResizeDimension(66.6)).toBe(67);
|
||||
});
|
||||
|
||||
it("floors at 1px", () => {
|
||||
expect(clampResizeDimension(0)).toBe(1);
|
||||
expect(clampResizeDimension(-5)).toBe(1);
|
||||
});
|
||||
|
||||
it("caps at the max dimension", () => {
|
||||
expect(clampResizeDimension(99999)).toBe(MAX_RESIZE_DIMENSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RESIZE_RATIO_PRESETS", () => {
|
||||
it("includes the common landscape and portrait ratios", () => {
|
||||
const ids = RESIZE_RATIO_PRESETS.map((p) => p.id);
|
||||
expect(ids).toContain("1:1");
|
||||
expect(ids).toContain("16:9");
|
||||
expect(ids).toContain("9:16");
|
||||
expect(ids).toContain("4:3");
|
||||
});
|
||||
|
||||
it("stores each ratio as width divided by height", () => {
|
||||
const r = RESIZE_RATIO_PRESETS.find((p) => p.id === "16:9");
|
||||
expect(r?.value).toBeCloseTo(16 / 9);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user