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),
};
}