import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Slider, Typography, } from "@mui/material"; import { useI18n } from "../i18n/I18nProvider"; const CROPPER_SIZE = 280; const OUTPUT_SIZE = 512; type DragState = { startX: number; startY: number; originX: number; originY: number; }; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } export default function CropImageDialog({ open, file, onClose, onSave, }: { open: boolean; file: File | null; onClose: () => void; onSave: (blob: Blob) => Promise | void; }) { const { t } = useI18n(); const [imageUrl, setImageUrl] = useState(null); const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null); const [zoom, setZoom] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const [saving, setSaving] = useState(false); const dragRef = useRef(null); const imgRef = useRef(null); useEffect(() => { if (!file || !open) { setImageUrl(null); setImageSize(null); setZoom(1); setPosition({ x: 0, y: 0 }); return; } const url = URL.createObjectURL(file); setImageUrl(url); setImageSize(null); setZoom(1); setPosition({ x: 0, y: 0 }); return () => URL.revokeObjectURL(url); }, [file, open]); const rendered = useMemo(() => { if (!imageSize) return null; const scale = Math.max(CROPPER_SIZE / imageSize.width, CROPPER_SIZE / imageSize.height) * zoom; const width = imageSize.width * scale; const height = imageSize.height * scale; return { scale, width, height }; }, [imageSize, zoom]); useEffect(() => { if (!rendered) return; const minX = Math.min(0, CROPPER_SIZE - rendered.width); const minY = Math.min(0, CROPPER_SIZE - rendered.height); setPosition((prev) => ({ x: clamp(prev.x, minX, 0), y: clamp(prev.y, minY, 0), })); }, [rendered]); const beginDrag = (clientX: number, clientY: number) => { dragRef.current = { startX: clientX, startY: clientY, originX: position.x, originY: position.y, }; }; const moveDrag = useCallback((clientX: number, clientY: number) => { if (!dragRef.current || !rendered) return; const minX = Math.min(0, CROPPER_SIZE - rendered.width); const minY = Math.min(0, CROPPER_SIZE - rendered.height); const nextX = dragRef.current.originX + (clientX - dragRef.current.startX); const nextY = dragRef.current.originY + (clientY - dragRef.current.startY); setPosition({ x: clamp(nextX, minX, 0), y: clamp(nextY, minY, 0) }); }, [rendered]); useEffect(() => { const onMouseMove = (event: MouseEvent) => moveDrag(event.clientX, event.clientY); const onTouchMove = (event: TouchEvent) => { const touch = event.touches[0]; if (touch) moveDrag(touch.clientX, touch.clientY); }; const stop = () => { dragRef.current = null; }; window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", stop); window.addEventListener("touchmove", onTouchMove, { passive: false }); window.addEventListener("touchend", stop); return () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", stop); window.removeEventListener("touchmove", onTouchMove); window.removeEventListener("touchend", stop); }; }, [moveDrag]); const exportCrop = async () => { if (!file || !rendered) return; const image = imgRef.current; if (!image) return; const canvas = document.createElement("canvas"); canvas.width = OUTPUT_SIZE; canvas.height = OUTPUT_SIZE; const ctx = canvas.getContext("2d"); if (!ctx) return; const cropX = -position.x / rendered.scale; const cropY = -position.y / rendered.scale; const cropWidth = CROPPER_SIZE / rendered.scale; const cropHeight = CROPPER_SIZE / rendered.scale; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE); setSaving(true); try { const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png", 0.95)); if (!blob) return; await onSave(blob); } finally { setSaving(false); } }; return ( {t("cropDialogTitle")} {t("cropDialogBody")} beginDrag(e.clientX, e.clientY)} onTouchStart={(e) => { const touch = e.touches[0]; if (touch) beginDrag(touch.clientX, touch.clientY); }} > {imageUrl ? ( {t("cropDialogPreviewAlt")} { const target = e.currentTarget; setImageSize({ width: target.naturalWidth, height: target.naturalHeight }); }} draggable={false} style={{ position: "absolute", left: position.x, top: position.y, width: rendered?.width ?? "auto", height: rendered?.height ?? "auto", maxWidth: "none", pointerEvents: "none", }} /> ) : null} {t("cropDialogZoom")} setZoom(value as number)} /> ); }