501 lines
16 KiB
TypeScript
501 lines
16 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;
|
||
|
||
// Set canvas size to crop area
|
||
canvas.width = cropArea.width;
|
||
canvas.height = cropArea.height;
|
||
|
||
// Clear canvas
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Draw the cropped portion
|
||
ctx.drawImage(
|
||
img,
|
||
Math.max(0, adjustedCropX),
|
||
Math.max(0, adjustedCropY),
|
||
Math.min(adjustedCropWidth, img.width - Math.max(0, adjustedCropX)),
|
||
Math.min(adjustedCropHeight, img.height - Math.max(0, adjustedCropY)),
|
||
0,
|
||
0,
|
||
canvas.width,
|
||
canvas.height
|
||
);
|
||
|
||
// Convert to blob and save
|
||
canvas.toBlob(
|
||
(blob) => {
|
||
if (blob) {
|
||
onSave(blob);
|
||
}
|
||
onClose();
|
||
},
|
||
"image/jpeg",
|
||
0.9
|
||
);
|
||
};
|
||
|
||
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>
|
||
);
|
||
}
|