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), panX: z.number().min(-100).max(100).default(0),
panY: 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), zoom: z.number().min(1).max(3).default(1),
objectFit: z.enum(["cover", "contain"]).default("cover"),
}); });
const settingsSchema = z.object({ const settingsSchema = z.object({
@@ -528,7 +529,7 @@ export function registerCollage(app: FastifyInstance) {
const rect = cellRects[i]; const rect = cellRects[i];
const cellW = Math.max(1, Math.round(rect.w)); const cellW = Math.max(1, Math.round(rect.w));
const cellH = Math.max(1, Math.round(rect.h)); 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 // Get image metadata for proper crop calculation
const meta = await sharp(files[i].buffer).metadata(); const meta = await sharp(files[i].buffer).metadata();
@@ -536,29 +537,57 @@ export function registerCollage(app: FastifyInstance) {
const imgH = meta.height ?? cellH; const imgH = meta.height ?? cellH;
const zoom = Math.max(1, cellSetting.zoom); const zoom = Math.max(1, cellSetting.zoom);
const fitMode = cellSetting.objectFit ?? "cover";
// Calculate the size we need to resize to before extracting let cellBuffer: Buffer;
// 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);
// Pan offset: percentage of the available overflow if (fitMode === "contain") {
const overflowX = Math.max(0, resizedW - cellW); // Contain: fit entire image inside the cell, fill rest with background
const overflowY = Math.max(0, resizedH - cellH); const bgColor =
// Center by default, then apply pan (-100..100 maps to full overflow range) settings.backgroundColor === "transparent"
const extractLeft = Math.round(overflowX / 2 - (cellSetting.panX / 100) * (overflowX / 2)); ? { r: 0, g: 0, b: 0, alpha: 0 }
const extractTop = Math.round(overflowY / 2 - (cellSetting.panY / 100) * (overflowY / 2)); : (() => {
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) const overflowX = Math.max(0, resizedW - cellW);
.resize(resizedW, resizedH, { fit: "fill" }) const overflowY = Math.max(0, resizedH - cellH);
.extract({ const extractLeft = Math.round(
left: Math.max(0, Math.min(extractLeft, resizedW - cellW)), overflowX / 2 - (cellSetting.panX / 100) * (overflowX / 2),
top: Math.max(0, Math.min(extractTop, resizedH - cellH)), );
width: cellW, const extractTop = Math.round(overflowY / 2 - (cellSetting.panY / 100) * (overflowY / 2));
height: cellH,
}) cellBuffer = await sharp(files[i].buffer)
.toBuffer(); .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 // Apply corner radius via SVG mask if needed
if (settings.cornerRadius > 0) { if (settings.cornerRadius > 0) {
@@ -11,7 +11,17 @@ import {
useSensors, useSensors,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { useDrag, usePinch } from "@use-gesture/react"; 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 DragEvent, useCallback, useEffect, useRef, useState } from "react";
import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates"; import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -373,6 +383,15 @@ function CollageCell({
[cellIndex, store], [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( const handleReset = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -431,7 +450,10 @@ function CollageCell({
src={displayUrl(image)} src={displayUrl(image)}
alt="" alt=""
draggable={false} 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={{ style={{
transform: `translate(${transform.panX}%, ${transform.panY}%) scale(${transform.zoom})`, 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"> <span className="text-white text-[10px] font-mono w-7 text-right shrink-0">
{transform.zoom.toFixed(1)}x {transform.zoom.toFixed(1)}x
</span> </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 <button
type="button" type="button"
onClick={handleReset} onClick={handleReset}
@@ -76,8 +76,8 @@ export function CollageSettings() {
} }
const cells = template.cells.map((_, i) => { const cells = template.cells.map((_, i) => {
const t = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1 }; const t = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1, objectFit: "cover" };
return { imageIndex: i, panX: t.panX, panY: t.panY, zoom: t.zoom }; return { imageIndex: i, panX: t.panX, panY: t.panY, zoom: t.zoom, objectFit: t.objectFit };
}); });
formData.append( formData.append(
+4 -1
View File
@@ -14,10 +14,13 @@ export interface CollageImage {
previewLoading: boolean; previewLoading: boolean;
} }
export type ObjectFit = "cover" | "contain";
export interface CellTransform { export interface CellTransform {
panX: number; // percentage -100..100 panX: number; // percentage -100..100
panY: number; // percentage -100..100 panY: number; // percentage -100..100
zoom: number; // 1.0 to 3.0 zoom: number; // 1.0 to 3.0
objectFit: ObjectFit;
} }
interface CollageState { interface CollageState {
@@ -76,7 +79,7 @@ interface CollageState {
reset: () => void; 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; let nextImageId = 0;