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:
SnapOtter
2026-07-16 16:12:08 +08:00
committed by GitHub
parent 0e608e1524
commit d88999e7a9
25 changed files with 317 additions and 23 deletions
@@ -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>
+56
View File
@@ -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),
};
}
+3
View File
@@ -1189,6 +1189,9 @@ export const ar: TranslationKeys = {
scale: "مقياس",
presets: "قوالب جاهزة",
contentAware: "مراعاة المحتوى",
aspectRatio: "نسبة العرض إلى الارتفاع",
ratioFree: "حر",
ratioOriginal: "الأصلي",
widthPx: "العرض (px)",
heightPx: "الارتفاع (px)",
fitMode: "وضع الملاءمة",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -1021,6 +1021,9 @@ export const hi: TranslationKeys = {
scale: "स्केल",
presets: "प्रीसेट",
contentAware: "कंटेंट-अवेयर",
aspectRatio: "आस्पेक्ट अनुपात",
ratioFree: "मुक्त",
ratioOriginal: "मूल",
widthPx: "चौड़ाई (px)",
heightPx: "ऊंचाई (px)",
fitMode: "फिट मोड",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -1160,6 +1160,9 @@ export const ja: TranslationKeys = {
scale: "スケール",
presets: "プリセット",
contentAware: "コンテンツ認識",
aspectRatio: "アスペクト比",
ratioFree: "自由",
ratioOriginal: "元の比率",
widthPx: "幅(px",
heightPx: "高さ(px",
fitMode: "フィットモード",
+3
View File
@@ -1144,6 +1144,9 @@ export const ko: TranslationKeys = {
scale: "스케일",
presets: "프리셋",
contentAware: "콘텐츠 인식",
aspectRatio: "종횡비",
ratioFree: "자유",
ratioOriginal: "원본",
widthPx: "너비 (px)",
heightPx: "높이 (px)",
fitMode: "맞춤 모드",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -1200,6 +1200,9 @@ export const ru: TranslationKeys = {
scale: "Масштаб",
presets: "Шаблоны",
contentAware: "Контентно-зависимый",
aspectRatio: "Соотношение сторон",
ratioFree: "Свободно",
ratioOriginal: "Исходный",
widthPx: "Ширина (px)",
heightPx: "Высота (px)",
fitMode: "Режим вписывания",
+3
View File
@@ -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",
+3
View File
@@ -1183,6 +1183,9 @@ export const th: TranslationKeys = {
scale: "สเกล",
presets: "พรีเซ็ต",
contentAware: "รับรู้เนื้อหา",
aspectRatio: "อัตราส่วนภาพ",
ratioFree: "อิสระ",
ratioOriginal: "ต้นฉบับ",
widthPx: "ความกว้าง (px)",
heightPx: "ความสูง (px)",
fitMode: "โหมดพอดี",
+3
View File
@@ -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",
+3
View File
@@ -1200,6 +1200,9 @@ export const uk: TranslationKeys = {
scale: "Масштаб",
presets: "Шаблони",
contentAware: "Контентно-залежний",
aspectRatio: "Співвідношення сторін",
ratioFree: "Вільно",
ratioOriginal: "Оригінал",
widthPx: "Ширина (px)",
heightPx: "Висота (px)",
fitMode: "Режим вписування",
+3
View File
@@ -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",
+3
View File
@@ -971,6 +971,9 @@ export const zhCN: TranslationKeys = {
scale: "缩放",
presets: "预设",
contentAware: "内容感知",
aspectRatio: "宽高比",
ratioFree: "自由",
ratioOriginal: "原始",
widthPx: "宽度(px",
heightPx: "高度(px",
fitMode: "适应模式",
+3
View File
@@ -971,6 +971,9 @@ export const zhTW: TranslationKeys = {
scale: "縮放",
presets: "預設",
contentAware: "內容感知",
aspectRatio: "長寬比",
ratioFree: "自由",
ratioOriginal: "原始",
widthPx: "寬度(px",
heightPx: "高度(px",
fitMode: "適應模式",
+18 -11
View File
@@ -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");
});
});
// ========================================================================
+91
View File
@@ -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);
});
});