mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user