Files
jobtrackingapp/job-tracker-ui/src/components/CropImageDialog.tsx
T
2026-08-29 23:51:01 +02:00

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