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),
|
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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user