Vitron-Front/app/components/store/ImageEditor.tsx
2026-04-29 01:44:16 +03:30

501 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}