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