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 { 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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { largestRatioBox, pairedDimension, RESIZE_RATIO_PRESETS } from "@/lib/aspect-ratio";
|
||||||
import { format } from "@/lib/format";
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -33,13 +34,16 @@ export interface ResizeControlsProps {
|
|||||||
|
|
||||||
export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) {
|
export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { currentEntry } = useFileStore();
|
||||||
const [tab, setTab] = useState<ResizeTab>("custom");
|
const [tab, setTab] = useState<ResizeTab>("custom");
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||||
const [width, setWidth] = useState<string>("");
|
const [width, setWidth] = useState<string>("");
|
||||||
const [height, setHeight] = useState<string>("");
|
const [height, setHeight] = useState<string>("");
|
||||||
const [percentage, setPercentage] = useState<string>("50");
|
const [percentage, setPercentage] = useState<string>("50");
|
||||||
const [fit, setFit] = useState<FitMode>("cover");
|
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 [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
||||||
const contentAware = tab === "content-aware";
|
const contentAware = tab === "content-aware";
|
||||||
const [protectFaces, setProtectFaces] = useState(false);
|
const [protectFaces, setProtectFaces] = useState(false);
|
||||||
@@ -107,6 +111,59 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
contentAware,
|
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 handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
||||||
const key = `${preset.platform}-${preset.name}`;
|
const key = `${preset.platform}-${preset.name}`;
|
||||||
if (selectedPreset === key) {
|
if (selectedPreset === key) {
|
||||||
@@ -123,6 +180,12 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
const tabClass = (t: ResizeTab) =>
|
const tabClass = (t: ResizeTab) =>
|
||||||
`flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
|
`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 = (
|
const dimensionInputs = (
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -133,20 +196,12 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
id="resize-width"
|
id="resize-width"
|
||||||
type="number"
|
type="number"
|
||||||
value={width}
|
value={width}
|
||||||
onChange={(e) => setWidth(e.target.value)}
|
onChange={(e) => handleWidthChange(e.target.value)}
|
||||||
placeholder="Auto"
|
placeholder="Auto"
|
||||||
disabled={squareMode && contentAware}
|
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"
|
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>
|
</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">
|
<div className="flex-1">
|
||||||
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
|
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
|
||||||
{t.toolSettings.resize.heightPx}
|
{t.toolSettings.resize.heightPx}
|
||||||
@@ -155,7 +210,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
id="resize-height"
|
id="resize-height"
|
||||||
type="number"
|
type="number"
|
||||||
value={height}
|
value={height}
|
||||||
onChange={(e) => setHeight(e.target.value)}
|
onChange={(e) => handleHeightChange(e.target.value)}
|
||||||
placeholder="Auto"
|
placeholder="Auto"
|
||||||
disabled={squareMode && contentAware}
|
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"
|
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">
|
<div className="space-y-3">
|
||||||
{dimensionInputs}
|
{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 */}
|
{/* Fit mode */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">{t.toolSettings.resize.fitMode}</p>
|
<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: "مقياس",
|
scale: "مقياس",
|
||||||
presets: "قوالب جاهزة",
|
presets: "قوالب جاهزة",
|
||||||
contentAware: "مراعاة المحتوى",
|
contentAware: "مراعاة المحتوى",
|
||||||
|
aspectRatio: "نسبة العرض إلى الارتفاع",
|
||||||
|
ratioFree: "حر",
|
||||||
|
ratioOriginal: "الأصلي",
|
||||||
widthPx: "العرض (px)",
|
widthPx: "العرض (px)",
|
||||||
heightPx: "الارتفاع (px)",
|
heightPx: "الارتفاع (px)",
|
||||||
fitMode: "وضع الملاءمة",
|
fitMode: "وضع الملاءمة",
|
||||||
|
|||||||
@@ -1202,6 +1202,9 @@ export const de: TranslationKeys = {
|
|||||||
scale: "Skalierung",
|
scale: "Skalierung",
|
||||||
presets: "Vorlagen",
|
presets: "Vorlagen",
|
||||||
contentAware: "Inhaltsabhängig",
|
contentAware: "Inhaltsabhängig",
|
||||||
|
aspectRatio: "Seitenverhältnis",
|
||||||
|
ratioFree: "Frei",
|
||||||
|
ratioOriginal: "Original",
|
||||||
widthPx: "Breite (px)",
|
widthPx: "Breite (px)",
|
||||||
heightPx: "Höhe (px)",
|
heightPx: "Höhe (px)",
|
||||||
fitMode: "Anpassungsmodus",
|
fitMode: "Anpassungsmodus",
|
||||||
|
|||||||
@@ -1152,6 +1152,9 @@ export const en = {
|
|||||||
scale: "Scale",
|
scale: "Scale",
|
||||||
presets: "Presets",
|
presets: "Presets",
|
||||||
contentAware: "Content-Aware",
|
contentAware: "Content-Aware",
|
||||||
|
aspectRatio: "Aspect Ratio",
|
||||||
|
ratioFree: "Free",
|
||||||
|
ratioOriginal: "Original",
|
||||||
widthPx: "Width (px)",
|
widthPx: "Width (px)",
|
||||||
heightPx: "Height (px)",
|
heightPx: "Height (px)",
|
||||||
fitMode: "Fit Mode",
|
fitMode: "Fit Mode",
|
||||||
|
|||||||
@@ -1186,6 +1186,9 @@ export const es: TranslationKeys = {
|
|||||||
scale: "Escala",
|
scale: "Escala",
|
||||||
presets: "Predefinidos",
|
presets: "Predefinidos",
|
||||||
contentAware: "Consciente del contenido",
|
contentAware: "Consciente del contenido",
|
||||||
|
aspectRatio: "Relación de aspecto",
|
||||||
|
ratioFree: "Libre",
|
||||||
|
ratioOriginal: "Original",
|
||||||
widthPx: "Ancho (px)",
|
widthPx: "Ancho (px)",
|
||||||
heightPx: "Alto (px)",
|
heightPx: "Alto (px)",
|
||||||
fitMode: "Modo de ajuste",
|
fitMode: "Modo de ajuste",
|
||||||
|
|||||||
@@ -1208,6 +1208,9 @@ export const fr: TranslationKeys = {
|
|||||||
scale: "Échelle",
|
scale: "Échelle",
|
||||||
presets: "Préréglages",
|
presets: "Préréglages",
|
||||||
contentAware: "Intelligent",
|
contentAware: "Intelligent",
|
||||||
|
aspectRatio: "Rapport d'aspect",
|
||||||
|
ratioFree: "Libre",
|
||||||
|
ratioOriginal: "Original",
|
||||||
widthPx: "Largeur (px)",
|
widthPx: "Largeur (px)",
|
||||||
heightPx: "Hauteur (px)",
|
heightPx: "Hauteur (px)",
|
||||||
fitMode: "Mode d'ajustement",
|
fitMode: "Mode d'ajustement",
|
||||||
|
|||||||
@@ -1021,6 +1021,9 @@ export const hi: TranslationKeys = {
|
|||||||
scale: "स्केल",
|
scale: "स्केल",
|
||||||
presets: "प्रीसेट",
|
presets: "प्रीसेट",
|
||||||
contentAware: "कंटेंट-अवेयर",
|
contentAware: "कंटेंट-अवेयर",
|
||||||
|
aspectRatio: "आस्पेक्ट अनुपात",
|
||||||
|
ratioFree: "मुक्त",
|
||||||
|
ratioOriginal: "मूल",
|
||||||
widthPx: "चौड़ाई (px)",
|
widthPx: "चौड़ाई (px)",
|
||||||
heightPx: "ऊंचाई (px)",
|
heightPx: "ऊंचाई (px)",
|
||||||
fitMode: "फिट मोड",
|
fitMode: "फिट मोड",
|
||||||
|
|||||||
@@ -1198,6 +1198,9 @@ export const id: TranslationKeys = {
|
|||||||
scale: "Skala",
|
scale: "Skala",
|
||||||
presets: "Preset",
|
presets: "Preset",
|
||||||
contentAware: "Sadar Konten",
|
contentAware: "Sadar Konten",
|
||||||
|
aspectRatio: "Rasio Aspek",
|
||||||
|
ratioFree: "Bebas",
|
||||||
|
ratioOriginal: "Asli",
|
||||||
widthPx: "Lebar (px)",
|
widthPx: "Lebar (px)",
|
||||||
heightPx: "Tinggi (px)",
|
heightPx: "Tinggi (px)",
|
||||||
fitMode: "Mode Pas",
|
fitMode: "Mode Pas",
|
||||||
|
|||||||
@@ -1202,6 +1202,9 @@ export const it: TranslationKeys = {
|
|||||||
scale: "Scala",
|
scale: "Scala",
|
||||||
presets: "Preset",
|
presets: "Preset",
|
||||||
contentAware: "Consapevole del contenuto",
|
contentAware: "Consapevole del contenuto",
|
||||||
|
aspectRatio: "Proporzioni",
|
||||||
|
ratioFree: "Libero",
|
||||||
|
ratioOriginal: "Originale",
|
||||||
widthPx: "Larghezza (px)",
|
widthPx: "Larghezza (px)",
|
||||||
heightPx: "Altezza (px)",
|
heightPx: "Altezza (px)",
|
||||||
fitMode: "Modalità di adattamento",
|
fitMode: "Modalità di adattamento",
|
||||||
|
|||||||
@@ -1160,6 +1160,9 @@ export const ja: TranslationKeys = {
|
|||||||
scale: "スケール",
|
scale: "スケール",
|
||||||
presets: "プリセット",
|
presets: "プリセット",
|
||||||
contentAware: "コンテンツ認識",
|
contentAware: "コンテンツ認識",
|
||||||
|
aspectRatio: "アスペクト比",
|
||||||
|
ratioFree: "自由",
|
||||||
|
ratioOriginal: "元の比率",
|
||||||
widthPx: "幅(px)",
|
widthPx: "幅(px)",
|
||||||
heightPx: "高さ(px)",
|
heightPx: "高さ(px)",
|
||||||
fitMode: "フィットモード",
|
fitMode: "フィットモード",
|
||||||
|
|||||||
@@ -1144,6 +1144,9 @@ export const ko: TranslationKeys = {
|
|||||||
scale: "스케일",
|
scale: "스케일",
|
||||||
presets: "프리셋",
|
presets: "프리셋",
|
||||||
contentAware: "콘텐츠 인식",
|
contentAware: "콘텐츠 인식",
|
||||||
|
aspectRatio: "종횡비",
|
||||||
|
ratioFree: "자유",
|
||||||
|
ratioOriginal: "원본",
|
||||||
widthPx: "너비 (px)",
|
widthPx: "너비 (px)",
|
||||||
heightPx: "높이 (px)",
|
heightPx: "높이 (px)",
|
||||||
fitMode: "맞춤 모드",
|
fitMode: "맞춤 모드",
|
||||||
|
|||||||
@@ -1202,6 +1202,9 @@ export const nl: TranslationKeys = {
|
|||||||
scale: "Schaal",
|
scale: "Schaal",
|
||||||
presets: "Voorinstellingen",
|
presets: "Voorinstellingen",
|
||||||
contentAware: "Inhoudsbewust",
|
contentAware: "Inhoudsbewust",
|
||||||
|
aspectRatio: "Beeldverhouding",
|
||||||
|
ratioFree: "Vrij",
|
||||||
|
ratioOriginal: "Origineel",
|
||||||
widthPx: "Breedte (px)",
|
widthPx: "Breedte (px)",
|
||||||
heightPx: "Hoogte (px)",
|
heightPx: "Hoogte (px)",
|
||||||
fitMode: "Pasmodus",
|
fitMode: "Pasmodus",
|
||||||
|
|||||||
@@ -1200,6 +1200,9 @@ export const pl: TranslationKeys = {
|
|||||||
scale: "Skala",
|
scale: "Skala",
|
||||||
presets: "Szablony",
|
presets: "Szablony",
|
||||||
contentAware: "Z uwzględnieniem treści",
|
contentAware: "Z uwzględnieniem treści",
|
||||||
|
aspectRatio: "Proporcje",
|
||||||
|
ratioFree: "Dowolny",
|
||||||
|
ratioOriginal: "Oryginał",
|
||||||
widthPx: "Szerokość (px)",
|
widthPx: "Szerokość (px)",
|
||||||
heightPx: "Wysokość (px)",
|
heightPx: "Wysokość (px)",
|
||||||
fitMode: "Tryb dopasowania",
|
fitMode: "Tryb dopasowania",
|
||||||
|
|||||||
@@ -1200,6 +1200,9 @@ export const ptBR: TranslationKeys = {
|
|||||||
scale: "Escala",
|
scale: "Escala",
|
||||||
presets: "Predefinições",
|
presets: "Predefinições",
|
||||||
contentAware: "Consciente do conteúdo",
|
contentAware: "Consciente do conteúdo",
|
||||||
|
aspectRatio: "Proporção",
|
||||||
|
ratioFree: "Livre",
|
||||||
|
ratioOriginal: "Original",
|
||||||
widthPx: "Largura (px)",
|
widthPx: "Largura (px)",
|
||||||
heightPx: "Altura (px)",
|
heightPx: "Altura (px)",
|
||||||
fitMode: "Modo de ajuste",
|
fitMode: "Modo de ajuste",
|
||||||
|
|||||||
@@ -1200,6 +1200,9 @@ export const ru: TranslationKeys = {
|
|||||||
scale: "Масштаб",
|
scale: "Масштаб",
|
||||||
presets: "Шаблоны",
|
presets: "Шаблоны",
|
||||||
contentAware: "Контентно-зависимый",
|
contentAware: "Контентно-зависимый",
|
||||||
|
aspectRatio: "Соотношение сторон",
|
||||||
|
ratioFree: "Свободно",
|
||||||
|
ratioOriginal: "Исходный",
|
||||||
widthPx: "Ширина (px)",
|
widthPx: "Ширина (px)",
|
||||||
heightPx: "Высота (px)",
|
heightPx: "Высота (px)",
|
||||||
fitMode: "Режим вписывания",
|
fitMode: "Режим вписывания",
|
||||||
|
|||||||
@@ -1197,6 +1197,9 @@ export const sv: TranslationKeys = {
|
|||||||
scale: "Skala",
|
scale: "Skala",
|
||||||
presets: "Mallar",
|
presets: "Mallar",
|
||||||
contentAware: "Innehållsmedveten",
|
contentAware: "Innehållsmedveten",
|
||||||
|
aspectRatio: "Bildförhållande",
|
||||||
|
ratioFree: "Fri",
|
||||||
|
ratioOriginal: "Original",
|
||||||
widthPx: "Bredd (px)",
|
widthPx: "Bredd (px)",
|
||||||
heightPx: "Höjd (px)",
|
heightPx: "Höjd (px)",
|
||||||
fitMode: "Passningsmetod",
|
fitMode: "Passningsmetod",
|
||||||
|
|||||||
@@ -1183,6 +1183,9 @@ export const th: TranslationKeys = {
|
|||||||
scale: "สเกล",
|
scale: "สเกล",
|
||||||
presets: "พรีเซ็ต",
|
presets: "พรีเซ็ต",
|
||||||
contentAware: "รับรู้เนื้อหา",
|
contentAware: "รับรู้เนื้อหา",
|
||||||
|
aspectRatio: "อัตราส่วนภาพ",
|
||||||
|
ratioFree: "อิสระ",
|
||||||
|
ratioOriginal: "ต้นฉบับ",
|
||||||
widthPx: "ความกว้าง (px)",
|
widthPx: "ความกว้าง (px)",
|
||||||
heightPx: "ความสูง (px)",
|
heightPx: "ความสูง (px)",
|
||||||
fitMode: "โหมดพอดี",
|
fitMode: "โหมดพอดี",
|
||||||
|
|||||||
@@ -1200,6 +1200,9 @@ export const tr: TranslationKeys = {
|
|||||||
scale: "Ölçek",
|
scale: "Ölçek",
|
||||||
presets: "Şablonlar",
|
presets: "Şablonlar",
|
||||||
contentAware: "İçerik Duyarlı",
|
contentAware: "İçerik Duyarlı",
|
||||||
|
aspectRatio: "En Boy Oranı",
|
||||||
|
ratioFree: "Serbest",
|
||||||
|
ratioOriginal: "Orijinal",
|
||||||
widthPx: "Genişlik (px)",
|
widthPx: "Genişlik (px)",
|
||||||
heightPx: "Yükseklik (px)",
|
heightPx: "Yükseklik (px)",
|
||||||
fitMode: "Sığdırma Modu",
|
fitMode: "Sığdırma Modu",
|
||||||
|
|||||||
@@ -1200,6 +1200,9 @@ export const uk: TranslationKeys = {
|
|||||||
scale: "Масштаб",
|
scale: "Масштаб",
|
||||||
presets: "Шаблони",
|
presets: "Шаблони",
|
||||||
contentAware: "Контентно-залежний",
|
contentAware: "Контентно-залежний",
|
||||||
|
aspectRatio: "Співвідношення сторін",
|
||||||
|
ratioFree: "Вільно",
|
||||||
|
ratioOriginal: "Оригінал",
|
||||||
widthPx: "Ширина (px)",
|
widthPx: "Ширина (px)",
|
||||||
heightPx: "Висота (px)",
|
heightPx: "Висота (px)",
|
||||||
fitMode: "Режим вписування",
|
fitMode: "Режим вписування",
|
||||||
|
|||||||
@@ -1201,6 +1201,9 @@ export const vi: TranslationKeys = {
|
|||||||
scale: "Tỷ lệ",
|
scale: "Tỷ lệ",
|
||||||
presets: "Mẫu có sẵn",
|
presets: "Mẫu có sẵn",
|
||||||
contentAware: "Nhận diện nội dung",
|
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)",
|
widthPx: "Chiều rộng (px)",
|
||||||
heightPx: "Chiều cao (px)",
|
heightPx: "Chiều cao (px)",
|
||||||
fitMode: "Chế độ vừa vặn",
|
fitMode: "Chế độ vừa vặn",
|
||||||
|
|||||||
@@ -971,6 +971,9 @@ export const zhCN: TranslationKeys = {
|
|||||||
scale: "缩放",
|
scale: "缩放",
|
||||||
presets: "预设",
|
presets: "预设",
|
||||||
contentAware: "内容感知",
|
contentAware: "内容感知",
|
||||||
|
aspectRatio: "宽高比",
|
||||||
|
ratioFree: "自由",
|
||||||
|
ratioOriginal: "原始",
|
||||||
widthPx: "宽度(px)",
|
widthPx: "宽度(px)",
|
||||||
heightPx: "高度(px)",
|
heightPx: "高度(px)",
|
||||||
fitMode: "适应模式",
|
fitMode: "适应模式",
|
||||||
|
|||||||
@@ -971,6 +971,9 @@ export const zhTW: TranslationKeys = {
|
|||||||
scale: "縮放",
|
scale: "縮放",
|
||||||
presets: "預設",
|
presets: "預設",
|
||||||
contentAware: "內容感知",
|
contentAware: "內容感知",
|
||||||
|
aspectRatio: "長寬比",
|
||||||
|
ratioFree: "自由",
|
||||||
|
ratioOriginal: "原始",
|
||||||
widthPx: "寬度(px)",
|
widthPx: "寬度(px)",
|
||||||
heightPx: "高度(px)",
|
heightPx: "高度(px)",
|
||||||
fitMode: "適應模式",
|
fitMode: "適應模式",
|
||||||
|
|||||||
@@ -823,18 +823,25 @@ test.describe("GUI Essential Tools", () => {
|
|||||||
// RESIZE: LINKED ASPECT RATIO AUTO-UPDATE
|
// RESIZE: LINKED ASPECT RATIO AUTO-UPDATE
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
test.describe("Resize Aspect Ratio Linked Fields", () => {
|
test.describe("Resize Aspect Ratio Linked Fields", () => {
|
||||||
// The resize component stores lockAspect as UI state but does not
|
// Selecting a ratio chip on the Custom tab locks the width/height fields:
|
||||||
// auto-compute the paired dimension on input change. The width/height
|
// editing one recomputes the other. 16:9 is fixture-independent (1600 -> 900).
|
||||||
// onChange handlers call setWidth/setHeight independently. Linked
|
test("width auto-updates height when a ratio is locked", async ({ loggedInPage: page }) => {
|
||||||
// auto-update is not implemented in the current component, so these
|
await page.goto("/image/resize");
|
||||||
// tests cannot pass until that feature is added.
|
await uploadTestImage(page);
|
||||||
test.skip("width auto-updates height when aspect ratio locked", async ({
|
|
||||||
loggedInPage: _page,
|
|
||||||
}) => {});
|
|
||||||
|
|
||||||
test.skip("height auto-updates width when aspect ratio locked", async ({
|
await page.getByRole("button", { name: "16:9", exact: true }).click();
|
||||||
loggedInPage: _page,
|
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