feat: add per-cell fit/fill toggle to collage tool

Adds objectFit property to CellTransform (cover/contain). When set to
"contain", the entire image is shown within the cell with background
color fill. Toggle button in the cell controls toolbar switches between
modes. Server-side rendering handles both modes via Sharp.
This commit is contained in:
ashim-hq
2026-04-19 16:41:35 +08:00
parent a7094f3fa4
commit bb241d6044
4 changed files with 97 additions and 26 deletions
@@ -11,7 +11,17 @@ import {
useSensors,
} from "@dnd-kit/core";
import { useDrag, usePinch } from "@use-gesture/react";
import { Download, GripVertical, ImagePlus, Loader2, RotateCcw, Upload, X } from "lucide-react";
import {
Download,
Expand,
GripVertical,
ImagePlus,
Loader2,
Maximize,
RotateCcw,
Upload,
X,
} from "lucide-react";
import { type DragEvent, useCallback, useEffect, useRef, useState } from "react";
import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates";
import { cn } from "@/lib/utils";
@@ -373,6 +383,15 @@ function CollageCell({
[cellIndex, store],
);
const handleToggleFit = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const next = transform.objectFit === "cover" ? "contain" : "cover";
store.setCellTransform(cellIndex, { objectFit: next });
},
[cellIndex, store, transform.objectFit],
);
const handleReset = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
@@ -431,7 +450,10 @@ function CollageCell({
src={displayUrl(image)}
alt=""
draggable={false}
className="w-full h-full object-cover select-none pointer-events-none"
className={cn(
"w-full h-full select-none pointer-events-none",
transform.objectFit === "contain" ? "object-contain" : "object-cover",
)}
style={{
transform: `translate(${transform.panX}%, ${transform.panY}%) scale(${transform.zoom})`,
}}
@@ -484,6 +506,23 @@ function CollageCell({
<span className="text-white text-[10px] font-mono w-7 text-right shrink-0">
{transform.zoom.toFixed(1)}x
</span>
<button
type="button"
onClick={handleToggleFit}
className={cn(
"transition-colors shrink-0",
transform.objectFit === "contain"
? "text-blue-300 hover:text-blue-200"
: "text-white hover:text-white/80",
)}
title={transform.objectFit === "cover" ? "Fit entire image" : "Fill cell"}
>
{transform.objectFit === "contain" ? (
<Maximize className="h-3.5 w-3.5" />
) : (
<Expand className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={handleReset}
@@ -76,8 +76,8 @@ export function CollageSettings() {
}
const cells = template.cells.map((_, i) => {
const t = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1 };
return { imageIndex: i, panX: t.panX, panY: t.panY, zoom: t.zoom };
const t = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1, objectFit: "cover" };
return { imageIndex: i, panX: t.panX, panY: t.panY, zoom: t.zoom, objectFit: t.objectFit };
});
formData.append(
+4 -1
View File
@@ -14,10 +14,13 @@ export interface CollageImage {
previewLoading: boolean;
}
export type ObjectFit = "cover" | "contain";
export interface CellTransform {
panX: number; // percentage -100..100
panY: number; // percentage -100..100
zoom: number; // 1.0 to 3.0
objectFit: ObjectFit;
}
interface CollageState {
@@ -76,7 +79,7 @@ interface CollageState {
reset: () => void;
}
const DEFAULT_TRANSFORM: CellTransform = { panX: 0, panY: 0, zoom: 1 };
const DEFAULT_TRANSFORM: CellTransform = { panX: 0, panY: 0, zoom: 1, objectFit: "cover" };
let nextImageId = 0;