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
+50 -21
View File
@@ -322,6 +322,7 @@ const cellSchema = z.object({
panX: z.number().min(-100).max(100).default(0),
panY: z.number().min(-100).max(100).default(0),
zoom: z.number().min(1).max(3).default(1),
objectFit: z.enum(["cover", "contain"]).default("cover"),
});
const settingsSchema = z.object({
@@ -528,7 +529,7 @@ export function registerCollage(app: FastifyInstance) {
const rect = cellRects[i];
const cellW = Math.max(1, Math.round(rect.w));
const cellH = Math.max(1, Math.round(rect.h));
const cellSetting = cellSettings[i] ?? { panX: 0, panY: 0, zoom: 1 };
const cellSetting = cellSettings[i] ?? { panX: 0, panY: 0, zoom: 1, objectFit: "cover" };
// Get image metadata for proper crop calculation
const meta = await sharp(files[i].buffer).metadata();
@@ -536,29 +537,57 @@ export function registerCollage(app: FastifyInstance) {
const imgH = meta.height ?? cellH;
const zoom = Math.max(1, cellSetting.zoom);
const fitMode = cellSetting.objectFit ?? "cover";
// Calculate the size we need to resize to before extracting
// With zoom=1 and cover fit, we resize so the image fully covers the cell
const scaleToFit = Math.max(cellW / imgW, cellH / imgH);
const resizedW = Math.round(imgW * scaleToFit * zoom);
const resizedH = Math.round(imgH * scaleToFit * zoom);
let cellBuffer: Buffer;
// Pan offset: percentage of the available overflow
const overflowX = Math.max(0, resizedW - cellW);
const overflowY = Math.max(0, resizedH - cellH);
// Center by default, then apply pan (-100..100 maps to full overflow range)
const extractLeft = Math.round(overflowX / 2 - (cellSetting.panX / 100) * (overflowX / 2));
const extractTop = Math.round(overflowY / 2 - (cellSetting.panY / 100) * (overflowY / 2));
if (fitMode === "contain") {
// Contain: fit entire image inside the cell, fill rest with background
const bgColor =
settings.backgroundColor === "transparent"
? { r: 0, g: 0, b: 0, alpha: 0 }
: (() => {
const hex = settings.backgroundColor.replace("#", "");
return {
r: Number.parseInt(hex.substring(0, 2), 16),
g: Number.parseInt(hex.substring(2, 4), 16),
b: Number.parseInt(hex.substring(4, 6), 16),
alpha: 1,
};
})();
cellBuffer = await sharp(files[i].buffer)
.resize(Math.round(cellW * zoom), Math.round(cellH * zoom), {
fit: "inside",
withoutEnlargement: false,
})
.resize(cellW, cellH, {
fit: "contain",
background: bgColor,
})
.toBuffer();
} else {
// Cover: resize to fully fill the cell, then crop with pan offset
const scaleToFit = Math.max(cellW / imgW, cellH / imgH);
const resizedW = Math.round(imgW * scaleToFit * zoom);
const resizedH = Math.round(imgH * scaleToFit * zoom);
let cellBuffer = await sharp(files[i].buffer)
.resize(resizedW, resizedH, { fit: "fill" })
.extract({
left: Math.max(0, Math.min(extractLeft, resizedW - cellW)),
top: Math.max(0, Math.min(extractTop, resizedH - cellH)),
width: cellW,
height: cellH,
})
.toBuffer();
const overflowX = Math.max(0, resizedW - cellW);
const overflowY = Math.max(0, resizedH - cellH);
const extractLeft = Math.round(
overflowX / 2 - (cellSetting.panX / 100) * (overflowX / 2),
);
const extractTop = Math.round(overflowY / 2 - (cellSetting.panY / 100) * (overflowY / 2));
cellBuffer = await sharp(files[i].buffer)
.resize(resizedW, resizedH, { fit: "fill" })
.extract({
left: Math.max(0, Math.min(extractLeft, resizedW - cellW)),
top: Math.max(0, Math.min(extractTop, resizedH - cellH)),
width: cellW,
height: cellH,
})
.toBuffer();
}
// Apply corner radius via SVG mask if needed
if (settings.cornerRadius > 0) {
@@ -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;