225 lines
6.9 KiB
TypeScript
225 lines
6.9 KiB
TypeScript
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> | void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const [imageUrl, setImageUrl] = useState<string | null>(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<DragState | null>(null);
|
|
const imgRef = useRef<HTMLImageElement | null>(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<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png", 0.95));
|
|
if (!blob) return;
|
|
await onSave(blob);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onClose={saving ? undefined : onClose} fullWidth maxWidth="sm">
|
|
<DialogTitle>{t("cropDialogTitle")}</DialogTitle>
|
|
<DialogContent>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
|
{t("cropDialogBody")}
|
|
</Typography>
|
|
|
|
<Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}>
|
|
<Box
|
|
sx={{
|
|
width: CROPPER_SIZE,
|
|
height: CROPPER_SIZE,
|
|
borderRadius: "50%",
|
|
overflow: "hidden",
|
|
position: "relative",
|
|
bgcolor: "grey.100",
|
|
border: "1px solid",
|
|
borderColor: "divider",
|
|
cursor: "grab",
|
|
userSelect: "none",
|
|
touchAction: "none",
|
|
boxShadow: "inset 0 0 0 999px rgba(0,0,0,0.03)",
|
|
}}
|
|
onMouseDown={(e) => beginDrag(e.clientX, e.clientY)}
|
|
onTouchStart={(e) => {
|
|
const touch = e.touches[0];
|
|
if (touch) beginDrag(touch.clientX, touch.clientY);
|
|
}}
|
|
>
|
|
{imageUrl ? (
|
|
<img
|
|
ref={imgRef}
|
|
src={imageUrl}
|
|
alt={t("cropDialogPreviewAlt")}
|
|
onLoad={(e) => {
|
|
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}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ px: 1 }}>
|
|
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700 }}>{t("cropDialogZoom")}</Typography>
|
|
<Slider min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={onClose} disabled={saving}>{t("cancel")}</Button>
|
|
<Button variant="contained" onClick={() => void exportCrop()} disabled={!file || saving || !rendered}>
|
|
{saving ? t("profileUploading") : t("cropDialogSave")}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
}
|