The crop canvas was sized to cropArea.width/height, which are the editor's on-screen (CSS) pixels — a few hundred px. Phone photos were therefore downscaled to that size before upload, so cropped product images came out blurry (most visibly on iPhone, whose source photos are large). Size the canvas to the crop region in the source image's native pixels (adjustedCropWidth/Height, already computed) and draw 1:1. Bumped JPEG quality 0.9 -> 0.92. The backend still caps the final image to 1600px webp, so output size stays bounded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
511 lines
17 KiB
TypeScript
511 lines
17 KiB
TypeScript
import React, { useState, useRef, useCallback } from "react";
|
||
import { X, Check, Move } from "lucide-react";
|
||
import { Button } from "../ui/button";
|
||
|
||
interface ImageEditorProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
imageUrl: string;
|
||
onSave: (editedImageBlob: Blob) => void;
|
||
}
|
||
|
||
interface CropArea {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
export function ImageEditor({
|
||
isOpen,
|
||
onClose,
|
||
imageUrl,
|
||
onSave,
|
||
}: ImageEditorProps) {
|
||
const [cropArea, setCropArea] = useState<CropArea>({
|
||
x: 50,
|
||
y: 50,
|
||
width: 150,
|
||
height: 150,
|
||
});
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [isResizing, setIsResizing] = useState(false);
|
||
const [resizeDirection, setResizeDirection] = useState<
|
||
"se" | "sw" | "ne" | "nw" | null
|
||
>(null);
|
||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Helper function to get coordinates from mouse or touch event
|
||
const getEventCoordinates = (e: MouseEvent | TouchEvent) => {
|
||
if ("touches" in e && e.touches.length > 0) {
|
||
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||
}
|
||
return { x: (e as MouseEvent).clientX, y: (e as MouseEvent).clientY };
|
||
};
|
||
|
||
const handlePointerDown = (
|
||
e: React.MouseEvent | React.TouchEvent,
|
||
action: "drag" | "resize",
|
||
direction?: "se" | "sw" | "ne" | "nw"
|
||
) => {
|
||
e.preventDefault();
|
||
if (!containerRef.current) return;
|
||
|
||
const coords = getEventCoordinates(
|
||
e.nativeEvent as MouseEvent | TouchEvent
|
||
);
|
||
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
const relativeX = coords.x - rect.left;
|
||
const relativeY = coords.y - rect.top;
|
||
|
||
if (action === "drag") {
|
||
setIsDragging(true);
|
||
setDragStart({
|
||
x: relativeX - cropArea.x,
|
||
y: relativeY - cropArea.y,
|
||
});
|
||
} else {
|
||
setIsResizing(true);
|
||
setResizeDirection(direction || "se");
|
||
setDragStart({
|
||
x: relativeX,
|
||
y: relativeY,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handlePointerMove = useCallback(
|
||
(e: MouseEvent | TouchEvent) => {
|
||
if (!containerRef.current) return;
|
||
|
||
// Prevent default behavior for touch events to avoid scrolling
|
||
if ("touches" in e) {
|
||
e.preventDefault();
|
||
}
|
||
|
||
const coords = getEventCoordinates(e);
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
|
||
// Convert global coordinates to container-relative coordinates
|
||
const relativeX = coords.x - rect.left;
|
||
const relativeY = coords.y - rect.top;
|
||
|
||
if (isDragging) {
|
||
const newX = relativeX - dragStart.x;
|
||
const newY = relativeY - dragStart.y;
|
||
|
||
setCropArea((prev) => ({
|
||
...prev,
|
||
x: Math.max(0, Math.min(rect.width - prev.width, newX)),
|
||
y: Math.max(0, Math.min(rect.height - prev.height, newY)),
|
||
}));
|
||
} else if (isResizing && resizeDirection) {
|
||
const deltaX = relativeX - dragStart.x;
|
||
const deltaY = relativeY - dragStart.y;
|
||
|
||
setCropArea((prev) => {
|
||
let newX = prev.x;
|
||
let newY = prev.y;
|
||
let newWidth = prev.width;
|
||
let newHeight = prev.height;
|
||
|
||
switch (resizeDirection) {
|
||
case "se": // Southeast (bottom-right)
|
||
newWidth = Math.max(
|
||
30,
|
||
Math.min(rect.width - prev.x, prev.width + deltaX)
|
||
);
|
||
newHeight = Math.max(
|
||
30,
|
||
Math.min(rect.height - prev.y, prev.height + deltaY)
|
||
);
|
||
break;
|
||
case "sw": // Southwest (bottom-left)
|
||
newWidth = Math.max(30, prev.width - deltaX);
|
||
newHeight = Math.max(
|
||
30,
|
||
Math.min(rect.height - prev.y, prev.height + deltaY)
|
||
);
|
||
newX = Math.max(
|
||
0,
|
||
Math.min(prev.x + prev.width - newWidth, prev.x + deltaX)
|
||
);
|
||
break;
|
||
case "ne": // Northeast (top-right)
|
||
newWidth = Math.max(
|
||
30,
|
||
Math.min(rect.width - prev.x, prev.width + deltaX)
|
||
);
|
||
newHeight = Math.max(30, prev.height - deltaY);
|
||
newY = Math.max(
|
||
0,
|
||
Math.min(prev.y + prev.height - newHeight, prev.y + deltaY)
|
||
);
|
||
break;
|
||
case "nw": // Northwest (top-left)
|
||
newWidth = Math.max(30, prev.width - deltaX);
|
||
newHeight = Math.max(30, prev.height - deltaY);
|
||
newX = Math.max(
|
||
0,
|
||
Math.min(prev.x + prev.width - newWidth, prev.x + deltaX)
|
||
);
|
||
newY = Math.max(
|
||
0,
|
||
Math.min(prev.y + prev.height - newHeight, prev.y + deltaY)
|
||
);
|
||
break;
|
||
}
|
||
|
||
return {
|
||
x: newX,
|
||
y: newY,
|
||
width: newWidth,
|
||
height: newHeight,
|
||
};
|
||
});
|
||
|
||
// Update drag start for continuous resizing - using relative coordinates
|
||
setDragStart({ x: relativeX, y: relativeY });
|
||
}
|
||
},
|
||
[isDragging, isResizing, resizeDirection, dragStart]
|
||
);
|
||
|
||
const handlePointerUp = useCallback(() => {
|
||
setIsDragging(false);
|
||
setIsResizing(false);
|
||
setResizeDirection(null);
|
||
}, []);
|
||
|
||
// Add event listeners for both mouse and touch events
|
||
React.useEffect(() => {
|
||
if (isDragging || isResizing) {
|
||
// Mouse events
|
||
document.addEventListener("mousemove", handlePointerMove);
|
||
document.addEventListener("mouseup", handlePointerUp);
|
||
|
||
// Touch events with passive: false to allow preventDefault
|
||
document.addEventListener("touchmove", handlePointerMove, {
|
||
passive: false,
|
||
});
|
||
document.addEventListener("touchend", handlePointerUp);
|
||
document.addEventListener("touchcancel", handlePointerUp);
|
||
|
||
return () => {
|
||
document.removeEventListener("mousemove", handlePointerMove);
|
||
document.removeEventListener("mouseup", handlePointerUp);
|
||
document.removeEventListener("touchmove", handlePointerMove);
|
||
document.removeEventListener("touchend", handlePointerUp);
|
||
document.removeEventListener("touchcancel", handlePointerUp);
|
||
};
|
||
}
|
||
}, [isDragging, isResizing, handlePointerMove, handlePointerUp]);
|
||
|
||
// Initialize crop area to center when component opens
|
||
React.useEffect(() => {
|
||
if (isOpen && containerRef.current && imageUrl) {
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous"; // Add this to handle CORS for external images
|
||
img.onload = () => {
|
||
const rect = containerRef.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
|
||
// Calculate actual image display dimensions with object-contain
|
||
const containerAspect = rect.width / rect.height;
|
||
const imageAspect = img.width / img.height;
|
||
|
||
let displayWidth, displayHeight, offsetX, offsetY;
|
||
|
||
if (imageAspect > containerAspect) {
|
||
// Image is wider - fit to container width
|
||
displayWidth = rect.width;
|
||
displayHeight = rect.width / imageAspect;
|
||
offsetX = 0;
|
||
offsetY = (rect.height - displayHeight) / 2;
|
||
} else {
|
||
// Image is taller - fit to container height
|
||
displayWidth = rect.height * imageAspect;
|
||
displayHeight = rect.height;
|
||
offsetX = (rect.width - displayWidth) / 2;
|
||
offsetY = 0;
|
||
}
|
||
|
||
// Set initial crop area to center of actual image display area
|
||
const initialSize = Math.min(displayWidth, displayHeight) * 0.6;
|
||
setCropArea({
|
||
x: offsetX + (displayWidth - initialSize) / 2,
|
||
y: offsetY + (displayHeight - initialSize) / 2,
|
||
width: initialSize,
|
||
height: initialSize,
|
||
});
|
||
};
|
||
|
||
img.onerror = () => {
|
||
console.error("Failed to load image for initial display");
|
||
// Set a default crop area if image fails to load
|
||
setCropArea({
|
||
x: 50,
|
||
y: 50,
|
||
width: 150,
|
||
height: 150,
|
||
});
|
||
};
|
||
|
||
img.src = imageUrl;
|
||
}
|
||
}, [isOpen, imageUrl]);
|
||
|
||
const applyCropAndRotation = useCallback(() => {
|
||
if (!canvasRef.current || !containerRef.current) return;
|
||
|
||
const canvas = canvasRef.current;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous"; // Add this to handle CORS for external images
|
||
img.onload = () => {
|
||
// Get container dimensions
|
||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||
if (!containerRect) return;
|
||
|
||
// Calculate actual image display dimensions with object-contain
|
||
const containerAspect = containerRect.width / containerRect.height;
|
||
const imageAspect = img.width / img.height;
|
||
|
||
let displayWidth, displayHeight, offsetX, offsetY;
|
||
|
||
if (imageAspect > containerAspect) {
|
||
// Image is wider - fit to container width
|
||
displayWidth = containerRect.width;
|
||
displayHeight = containerRect.width / imageAspect;
|
||
offsetX = 0;
|
||
offsetY = (containerRect.height - displayHeight) / 2;
|
||
} else {
|
||
// Image is taller - fit to container height
|
||
displayWidth = containerRect.height * imageAspect;
|
||
displayHeight = containerRect.height;
|
||
offsetX = (containerRect.width - displayWidth) / 2;
|
||
offsetY = 0;
|
||
}
|
||
|
||
// Calculate scale factors based on actual displayed image size
|
||
const scaleX = img.width / displayWidth;
|
||
const scaleY = img.height / displayHeight;
|
||
|
||
// Adjust crop coordinates to account for image offset
|
||
const adjustedCropX = (cropArea.x - offsetX) * scaleX;
|
||
const adjustedCropY = (cropArea.y - offsetY) * scaleY;
|
||
const adjustedCropWidth = cropArea.width * scaleX;
|
||
const adjustedCropHeight = cropArea.height * scaleY;
|
||
|
||
// Crop rectangle expressed in the SOURCE image's native pixels
|
||
// (adjusted* already scales the on-screen crop back up to the original).
|
||
const srcX = Math.max(0, adjustedCropX);
|
||
const srcY = Math.max(0, adjustedCropY);
|
||
const srcWidth = Math.min(adjustedCropWidth, img.width - srcX);
|
||
const srcHeight = Math.min(adjustedCropHeight, img.height - srcY);
|
||
|
||
// Size the canvas to the full-resolution crop region, NOT the editor's
|
||
// on-screen crop size. Using the display size here was downscaling phone
|
||
// photos to a few hundred pixels, which is what made uploads blurry.
|
||
// The backend still caps the final image to 1600px.
|
||
canvas.width = Math.max(1, Math.round(srcWidth));
|
||
canvas.height = Math.max(1, Math.round(srcHeight));
|
||
|
||
// Clear canvas
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Draw the cropped portion at native resolution
|
||
ctx.drawImage(
|
||
img,
|
||
srcX,
|
||
srcY,
|
||
srcWidth,
|
||
srcHeight,
|
||
0,
|
||
0,
|
||
canvas.width,
|
||
canvas.height
|
||
);
|
||
|
||
// Convert to blob and save
|
||
canvas.toBlob(
|
||
(blob) => {
|
||
if (blob) {
|
||
onSave(blob);
|
||
}
|
||
onClose();
|
||
},
|
||
"image/jpeg",
|
||
0.92
|
||
);
|
||
};
|
||
|
||
img.onerror = () => {
|
||
console.error("Failed to load image for cropping");
|
||
// Still close the editor on error
|
||
onClose();
|
||
};
|
||
|
||
img.src = imageUrl;
|
||
}, [cropArea, imageUrl, onSave, onClose]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-BLACK bg-opacity-75 z-50 flex items-center justify-center p-4 touch-none">
|
||
<div className="bg-WHITE rounded-lg w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-4 border-b">
|
||
<h3 className="text-lg font-semibold">ویرایش تصویر</h3>
|
||
<button onClick={onClose} className="p-1 hover:bg-WHITE3 rounded">
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Image Editor Area */}
|
||
<div className="p-4">
|
||
<div
|
||
ref={containerRef}
|
||
className="relative w-full h-64 sm:h-80 bg-BLACK rounded-lg overflow-hidden mb-4 touch-none"
|
||
>
|
||
{/* Background Image */}
|
||
<img
|
||
src={imageUrl}
|
||
alt="Edit"
|
||
className="w-full h-full object-contain select-none"
|
||
style={{
|
||
transition: "transform 0.2s ease",
|
||
}}
|
||
draggable={false}
|
||
/>
|
||
|
||
{/* Crop Overlay */}
|
||
<div
|
||
className="absolute border-2 border-WHITE shadow-lg touch-none"
|
||
style={{
|
||
left: cropArea.x,
|
||
top: cropArea.y,
|
||
width: cropArea.width,
|
||
height: cropArea.height,
|
||
cursor: isDragging ? "grabbing" : "grab",
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
onMouseDown={(e) => handlePointerDown(e, "drag")}
|
||
onTouchStart={(e) => handlePointerDown(e, "drag")}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
// Handle keyboard interaction if needed
|
||
}
|
||
}}
|
||
>
|
||
{/* Crop area background */}
|
||
<div className="w-full h-full bg-transparent relative">
|
||
{/* Resize handles - all four corners */}
|
||
{/* Top-left */}
|
||
<div
|
||
className="absolute -top-1 -left-1 w-4 h-4 bg-WHITE border border-GRAY2 cursor-nw-resize touch-none"
|
||
role="button"
|
||
tabIndex={0}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
handlePointerDown(e, "resize", "nw");
|
||
}}
|
||
onTouchStart={(e) => {
|
||
e.stopPropagation();
|
||
handlePointerDown(e, "resize", "nw");
|
||
}}
|
||
/>
|
||
{/* Top-right */}
|
||
<div
|
||
className="absolute -top-1 -right-1 w-4 h-4 bg-WHITE border border-GRAY2 cursor-ne-resize touch-none"
|
||
role="button"
|
||
tabIndex={0}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
handlePointerDown(e, "resize", "ne");
|
||
}}
|
||
onTouchStart={(e) => {
|
||
e.stopPropagation();
|
||
handlePointerDown(e, "resize", "ne");
|
||
}}
|
||
/>
|
||
{/* Bottom-left */}
|
||
<div
|
||
className="absolute -bottom-1 -left-1 w-4 h-4 bg-WHITE border border-GRAY2 cursor-sw-resize touch-none"
|
||
role="button"
|
||
tabIndex={0}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
handlePointerDown(e, "resize", "sw");
|
||
}}
|
||
onTouchStart={(e) => {
|
||
e.stopPropagation();
|
||
handlePointerDown(e, "resize", "sw");
|
||
}}
|
||
/>
|
||
{/* Bottom-right */}
|
||
<div
|
||
className="absolute -bottom-1 -right-1 w-4 h-4 bg-WHITE border border-GRAY2 cursor-se-resize touch-none"
|
||
role="button"
|
||
tabIndex={0}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
handlePointerDown(e, "resize", "se");
|
||
}}
|
||
onTouchStart={(e) => {
|
||
e.stopPropagation();
|
||
handlePointerDown(e, "resize", "se");
|
||
}}
|
||
/>
|
||
{/* Move icon */}
|
||
<div className="absolute top-1 left-1 text-WHITE opacity-75">
|
||
<Move size={14} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Dark overlay outside crop area */}
|
||
<div
|
||
className="absolute inset-0 bg-BLACK bg-opacity-50 pointer-events-none"
|
||
style={{
|
||
clipPath: `polygon(0% 0%, 0% 100%, ${cropArea.x}px 100%, ${cropArea.x}px ${cropArea.y}px, ${cropArea.x + cropArea.width}px ${cropArea.y}px, ${cropArea.x + cropArea.width}px ${cropArea.y + cropArea.height}px, ${cropArea.x}px ${cropArea.y + cropArea.height}px, ${cropArea.x}px 100%, 100% 100%, 100% 0%)`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex gap-3 p-4 border-t">
|
||
<Button
|
||
size="lg"
|
||
className="w-full flex-1"
|
||
variant="primary"
|
||
onClick={onClose}
|
||
>
|
||
لغو
|
||
</Button>
|
||
<Button
|
||
size="lg"
|
||
className="w-full flex-2"
|
||
variant="dark"
|
||
onClick={applyCropAndRotation}
|
||
>
|
||
<Check size={16} className="ml-2" />
|
||
اعمال تغییرات
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hidden canvas for processing */}
|
||
<canvas ref={canvasRef} className="hidden" />
|
||
</div>
|
||
);
|
||
}
|