feat(29-03): create ImageCropEditor component
Zoom+pan editor using react-easy-crop with zoom slider, save/cancel buttons, and dominant color background. Returns crop coordinates for persistence. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
135
src/client/components/ImageCropEditor.tsx
Normal file
135
src/client/components/ImageCropEditor.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import Cropper from "react-easy-crop";
|
||||||
|
import type { Area, Point } from "react-easy-crop";
|
||||||
|
|
||||||
|
interface CropResult {
|
||||||
|
zoom: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageCropEditorProps {
|
||||||
|
imageUrl: string;
|
||||||
|
dominantColor?: string | null;
|
||||||
|
initialZoom?: number;
|
||||||
|
initialX?: number;
|
||||||
|
initialY?: number;
|
||||||
|
aspect?: number;
|
||||||
|
onSave: (result: CropResult) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageCropEditor({
|
||||||
|
imageUrl,
|
||||||
|
dominantColor,
|
||||||
|
initialZoom = 1,
|
||||||
|
initialX = 0,
|
||||||
|
initialY = 0,
|
||||||
|
aspect = 4 / 3,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: ImageCropEditorProps) {
|
||||||
|
const [crop, setCrop] = useState<Point>({ x: initialX, y: initialY });
|
||||||
|
const [zoom, setZoom] = useState(initialZoom);
|
||||||
|
|
||||||
|
const onCropComplete = useCallback(
|
||||||
|
(_croppedArea: Area, _croppedAreaPixels: Area) => {
|
||||||
|
// Crop/zoom state is tracked via setCrop/setZoom, not this callback
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
onSave({
|
||||||
|
zoom,
|
||||||
|
x: crop.x,
|
||||||
|
y: crop.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Crop area */}
|
||||||
|
<div
|
||||||
|
className="relative w-full overflow-hidden rounded-xl"
|
||||||
|
style={{ aspectRatio: `${aspect}` }}
|
||||||
|
>
|
||||||
|
<Cropper
|
||||||
|
image={imageUrl}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={aspect}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={onCropComplete}
|
||||||
|
minZoom={1}
|
||||||
|
maxZoom={3}
|
||||||
|
style={{
|
||||||
|
containerStyle: {
|
||||||
|
backgroundColor: dominantColor || "#f3f4f6",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
objectFit="contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom slider */}
|
||||||
|
<div className="flex items-center gap-3 px-1">
|
||||||
|
<label htmlFor="crop-zoom" className="sr-only">
|
||||||
|
Zoom
|
||||||
|
</label>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-gray-400 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
<path d="M8 11h6" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
id="crop-zoom"
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1.5 bg-gray-200 rounded-full appearance-none cursor-pointer accent-gray-900"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-gray-400 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
<path d="M8 11h6M11 8v6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-2 text-sm font-semibold text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Save framing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user