Files
SnapOtter/apps/web/src/components/tools/crop-settings.tsx
T
fb33a46a64 feat: SOTA overhaul of automate pipeline page (#53)
* feat(find-duplicates): upgrade to 128-bit dHash with metadata and thumbnails

* feat(find-duplicates): add custom-results display mode and duplicate store

* feat(find-duplicates): add results overview grid and detail comparison view

* feat(find-duplicates): overhaul settings with sensitivity presets and download actions

* feat(find-duplicates): update i18n description

* chore: replace jsqr with zxing-wasm for barcode reading

* feat(barcode-read): rewrite backend with zxing-wasm for all barcode types

* feat(barcode-read): rewrite frontend with multi-file, results table, progress, export

- Multi-file sequential processing with per-file progress
- Structured results table with type badges and copy per-result
- Copy All and Export CSV functionality
- Thorough scan toggle (maps to tryHarder in zxing-wasm)
- Before/after view shows annotated image with bounding boxes
- Updated tool description in constants and i18n

* feat(stitch): update tool name and description for redesign

* feat(stitch): add grid layout, alignment, border, radius, quality, and new resize modes

* feat(stitch): redesign settings UI with grid, alignment, border, radius, quality

* test(stitch): add stitch to e2e tool navigation suite

* feat(vectorize): redesign with dual-engine backend and preset-driven UI

- Backend: potrace for B&W, VTracer (@neplex/vectorizer) for full-color vectorization
- Frontend: 5 presets (logo, illustration, photo, sketch, custom)
- Settings: color precision, gradient step, detail, smoothing, corner threshold, invert
- Updated OpenAPI spec and i18n description

* feat(border): redesign with presets, shadow, padding color, swatches

- Add 8 one-click presets (Clean White, Gallery Black, Shadow, Rounded, Polaroid, Vintage, Minimal, Cinematic)
- Implement proper shadow rendering with blur, offset X/Y, color, opacity
- Add padding color control (was hardcoded white)
- Add color swatches for quick color selection
- Wrap in form for Enter key submission
- Add smart validation (requires at least one effect active)
- Align frontend/backend slider ranges
- Organize UI with sections and collapsible shadow toggle

* feat(split): overhaul image splitting with live grid overlay and tile preview

- Add interactive-split display mode with SplitCanvas component
- Live SVG grid overlay on uploaded image showing split boundaries
- Two split modes: Grid (NxM) and Tile Size (px dimensions)
- 9 grid presets (2x1, 1x2, 2x2, 3x1, 1x3, 3x3, 2x3, 3x2, 4x4)
- Output format selection (original/PNG/JPG/WebP) with quality slider
- Post-split tile preview thumbnails with individual download
- Download All as ZIP button
- HEIC/HEIF preview with loading spinner
- Backend: tile-size mode, output format conversion, quality control
- Zustand store for split state management

* feat(split): rewrite backend and frontend settings

Backend: tile-size mode, output format conversion, quality control.
Frontend: split modes, presets, format selector, tile preview grid.

* feat(border): add live CSS preview and remove before/after slider

- Add imageWrapperStyle prop to ImageViewer for live border preview
- Add onImageStyle callback through tool-page to settings components
- Change border displayMode to no-comparison (no slider)
- BorderControls sends live CSS styles (border, padding, radius, shadow)
- Preview updates instantly as user adjusts sliders or clicks presets

* fix: repair i18n file corrupted by formatter during merge conflict resolution

* feat(border): enable live CSS preview in right pane as settings change

* fix(border): keep CSS preview visible after processing for WYSIWYG consistency

* chore: add @dnd-kit/core and @dnd-kit/sortable for pipeline drag-and-drop

* feat(pipeline): add Zustand store for pipeline step management

* feat(automate): add pipeline step settings summary utility with tests

* feat(automate): add POST /api/v1/pipeline/batch for multi-file pipeline execution

* feat(automate): add usePipelineProcessor hook for single and batch pipeline execution

* fix(automate): pass settings prop to all pipeline step controls for state restoration

* feat(automate): rewrite pipeline builder with dnd-kit drag-and-drop and compact step cards

* feat(automate): rewrite page with two-panel layout, image preview, and batch support

* test(automate): update e2e tests for new two-panel pipeline layout

---------

Co-authored-by: Siddharth Kumar Sah <siddharth123sk@gmail.com>
2026-04-13 16:26:38 +08:00

500 lines
17 KiB
TypeScript

import { ArrowLeftRight, Download, Grid3x3 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Crop } from "react-image-crop";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
const ASPECT_PRESETS = [
{ label: "Free", value: undefined as number | undefined },
{ label: "1:1", value: 1 },
{ label: "4:3", value: 4 / 3 },
{ label: "3:2", value: 3 / 2 },
{ label: "16:9", value: 16 / 9 },
{ label: "2:3", value: 2 / 3 },
{ label: "4:5", value: 4 / 5 },
{ label: "9:16", value: 9 / 16 },
];
export interface CropSettingsProps {
cropState: {
crop: Crop;
aspect: number | undefined;
showGrid: boolean;
imgDimensions: { width: number; height: number } | null;
};
onCropChange: (crop: Crop) => void;
onAspectChange: (aspect: number | undefined) => void;
onGridToggle: (show: boolean) => void;
}
export function CropSettings({
cropState,
onCropChange,
onAspectChange,
onGridToggle,
}: CropSettingsProps) {
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("crop");
const { crop, aspect, showGrid, imgDimensions } = cropState;
const [customMode, setCustomMode] = useState(false);
const [customW, setCustomW] = useState("3");
const [customH, setCustomH] = useState("2");
// Convert percentage crop to pixel values
const toPixels = useCallback(
(c: Crop) => {
if (!imgDimensions) return { left: 0, top: 0, width: 0, height: 0 };
return {
left: Math.round((c.x / 100) * imgDimensions.width),
top: Math.round((c.y / 100) * imgDimensions.height),
width: Math.round((c.width / 100) * imgDimensions.width),
height: Math.round((c.height / 100) * imgDimensions.height),
};
},
[imgDimensions],
);
// Convert pixel value change back to percentage crop
const handlePixelChange = useCallback(
(field: "left" | "top" | "width" | "height", value: number) => {
if (!imgDimensions) return;
const newCrop = { ...crop };
if (field === "left") {
newCrop.x = Math.max(0, Math.min((value / imgDimensions.width) * 100, 100 - newCrop.width));
} else if (field === "top") {
newCrop.y = Math.max(
0,
Math.min((value / imgDimensions.height) * 100, 100 - newCrop.height),
);
} else if (field === "width") {
const pct = Math.max(0, Math.min((value / imgDimensions.width) * 100, 100 - newCrop.x));
newCrop.width = pct;
if (aspect) {
newCrop.height = Math.min(
(pct / 100) * imgDimensions.width * (1 / aspect) * (100 / imgDimensions.height),
100 - newCrop.y,
);
newCrop.y = Math.max(0, Math.min(newCrop.y, 100 - newCrop.height));
}
} else if (field === "height") {
const pct = Math.max(0, Math.min((value / imgDimensions.height) * 100, 100 - newCrop.y));
newCrop.height = pct;
if (aspect) {
newCrop.width = Math.min(
(pct / 100) * imgDimensions.height * aspect * (100 / imgDimensions.width),
100 - newCrop.x,
);
newCrop.x = Math.max(0, Math.min(newCrop.x, 100 - newCrop.width));
}
}
onCropChange(newCrop);
},
[crop, imgDimensions, aspect, onCropChange],
);
const handleAspectSelect = useCallback(
(value: number | undefined) => {
setCustomMode(false);
onAspectChange(value);
// When selecting an aspect ratio, adjust the current crop to match
if (value && imgDimensions) {
const imgAspect = imgDimensions.width / imgDimensions.height;
let newWidth: number;
let newHeight: number;
if (value > imgAspect) {
// Desired ratio is wider than image — use full width, shrink height
newWidth = 100;
newHeight = (imgDimensions.width / value / imgDimensions.height) * 100;
} else {
// Desired ratio is taller than image — use full height, shrink width
newHeight = 100;
newWidth = ((imgDimensions.height * value) / imgDimensions.width) * 100;
}
onCropChange({
unit: "%",
x: (100 - newWidth) / 2,
y: (100 - newHeight) / 2,
width: newWidth,
height: newHeight,
});
}
},
[onAspectChange, onCropChange, imgDimensions],
);
const applyCustomAspect = useCallback(
(w: number, h: number) => {
if (w > 0 && h > 0) {
const value = w / h;
onAspectChange(value);
if (imgDimensions) {
const imgAspect = imgDimensions.width / imgDimensions.height;
let newWidth: number;
let newHeight: number;
if (value > imgAspect) {
newWidth = 100;
newHeight = (imgDimensions.width / value / imgDimensions.height) * 100;
} else {
newHeight = 100;
newWidth = ((imgDimensions.height * value) / imgDimensions.width) * 100;
}
onCropChange({
unit: "%",
x: (100 - newWidth) / 2,
y: (100 - newHeight) / 2,
width: newWidth,
height: newHeight,
});
}
}
},
[onAspectChange, onCropChange, imgDimensions],
);
const handleCustomSelect = useCallback(() => {
setCustomMode(true);
const w = Number(customW);
const h = Number(customH);
applyCustomAspect(w, h);
}, [customW, customH, applyCustomAspect]);
const handleSwapAspect = useCallback(() => {
if (aspect) {
handleAspectSelect(1 / aspect);
}
}, [aspect, handleAspectSelect]);
const pixels = toPixels(crop);
const handleProcess = () => {
if (files.length > 1) {
// Send percentage-based crop so the server adapts to each image's dimensions
const settings = {
left: crop.x,
top: crop.y,
width: Math.max(0.1, crop.width),
height: Math.max(0.1, crop.height),
unit: "percent" as const,
};
processAllFiles(files, settings);
} else {
const settings = {
left: pixels.left,
top: pixels.top,
width: Math.max(1, pixels.width),
height: Math.max(1, pixels.height),
};
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const hasSize = pixels.width > 0 && pixels.height > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && hasSize && !processing) handleProcess();
};
// Find which preset label matches the current aspect
const activePresetLabel = ASPECT_PRESETS.find((p) => {
if (p.value === undefined && aspect === undefined) return true;
if (p.value !== undefined && aspect !== undefined) {
return Math.abs(p.value - aspect) < 0.01;
}
return false;
})?.label;
return (
<form onSubmit={handleSubmit} className="space-y-4" data-crop-form>
{/* Aspect Ratio */}
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-muted-foreground">Aspect Ratio</p>
{aspect !== undefined && (
<button
type="button"
onClick={handleSwapAspect}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Swap width/height"
>
<ArrowLeftRight className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex flex-wrap gap-1">
{ASPECT_PRESETS.map(({ label, value }) => (
<button
type="button"
key={label}
onClick={() => handleAspectSelect(value)}
className={`px-2 py-1.5 rounded text-xs transition-colors ${
!customMode && activePresetLabel === label
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
}`}
>
{label}
</button>
))}
<button
type="button"
onClick={handleCustomSelect}
className={`px-2 py-1.5 rounded text-xs transition-colors ${
customMode
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
}`}
>
Custom
</button>
</div>
{customMode && (
<div className="flex items-center gap-1.5 mt-2">
<input
type="number"
value={customW}
onChange={(e) => {
setCustomW(e.target.value);
const w = Number(e.target.value);
const h = Number(customH);
if (w > 0 && h > 0) applyCustomAspect(w, h);
}}
min={1}
className="w-16 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums text-center"
/>
<span className="text-xs text-muted-foreground">:</span>
<input
type="number"
value={customH}
onChange={(e) => {
setCustomH(e.target.value);
const w = Number(customW);
const h = Number(e.target.value);
if (w > 0 && h > 0) applyCustomAspect(w, h);
}}
min={1}
className="w-16 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums text-center"
/>
</div>
)}
</div>
{/* Position & Size */}
<div>
<p className="text-xs text-muted-foreground">Position & Size</p>
<div className="grid grid-cols-2 gap-2 mt-1">
<div>
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
X{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
id="crop-x"
type="number"
value={pixels.left}
onChange={(e) => handlePixelChange("left", Number(e.target.value))}
min={0}
max={imgDimensions ? imgDimensions.width - 1 : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
<div>
<label htmlFor="crop-y" className="text-[10px] text-muted-foreground">
Y{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
id="crop-y"
type="number"
value={pixels.top}
onChange={(e) => handlePixelChange("top", Number(e.target.value))}
min={0}
max={imgDimensions ? imgDimensions.height - 1 : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
<div>
<label htmlFor="crop-width" className="text-[10px] text-muted-foreground">
Width{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
</label>
<input
id="crop-width"
type="number"
value={pixels.width}
onChange={(e) => handlePixelChange("width", Number(e.target.value))}
min={1}
max={imgDimensions ? imgDimensions.width : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
<div>
<label htmlFor="crop-height" className="text-[10px] text-muted-foreground">
Height{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
</label>
<input
id="crop-height"
type="number"
value={pixels.height}
onChange={(e) => handlePixelChange("height", Number(e.target.value))}
min={1}
max={imgDimensions ? imgDimensions.height : undefined}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
/>
</div>
</div>
</div>
{/* Grid overlay toggle */}
<label className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs cursor-pointer select-none text-muted-foreground hover:text-foreground transition-colors">
<input
type="checkbox"
checked={showGrid}
onChange={(e) => onGridToggle(e.target.checked)}
className="accent-primary h-3.5 w-3.5"
/>
<Grid3x3 className="h-3.5 w-3.5" />
Rule of Thirds
</label>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Cropping"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="crop-submit"
disabled={!hasFile || !hasSize || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Crop (${files.length} files)` : "Crop"}
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="crop-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
// ── Pipeline-only crop controls (numeric inputs, no canvas) ──────────
export interface CropControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function CropControls({ settings: initialSettings, onChange }: CropControlsProps) {
const [left, setLeft] = useState(0);
const [top, setTop] = useState(0);
const [width, setWidth] = useState("");
const [height, setHeight] = useState("");
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.left != null) setLeft(Number(initialSettings.left));
if (initialSettings.top != null) setTop(Number(initialSettings.top));
if (initialSettings.width != null) setWidth(String(initialSettings.width));
if (initialSettings.height != null) setHeight(String(initialSettings.height));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
});
useEffect(() => {
onChangeRef.current?.({
left,
top,
width: width ? Number(width) : undefined,
height: height ? Number(height) : undefined,
});
}, [left, top, width, height]);
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<label htmlFor="pipeline-crop-left" className="text-xs text-muted-foreground">
Left offset (px)
</label>
<input
id="pipeline-crop-left"
type="number"
value={left}
onChange={(e) => setLeft(Number(e.target.value))}
min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="pipeline-crop-top" className="text-xs text-muted-foreground">
Top offset (px)
</label>
<input
id="pipeline-crop-top"
type="number"
value={top}
onChange={(e) => setTop(Number(e.target.value))}
min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="pipeline-crop-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="pipeline-crop-width"
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
min={1}
placeholder="Required"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="pipeline-crop-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="pipeline-crop-height"
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
min={1}
placeholder="Required"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
</div>
);
}