frontPanel/src/ui_kit/Modal/CropModal.tsx
2024-04-03 23:45:48 +03:00

454 lines
12 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 { devlog } from "@frontend/kitui";
import { CropIcon } from "@icons/CropIcon";
import { ResetIcon } from "@icons/ResetIcon";
import DeleteIcon from "@mui/icons-material/Delete";
import {
Box,
Button,
IconButton,
Modal,
Slider,
SxProps,
Theme,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { updateQuestion } from "@root/questions/actions";
import { enqueueSnackbar } from "notistack";
import { FC, useCallback, useMemo, useRef, useState } from "react";
import ReactCrop, {
PercentCrop,
centerCrop,
convertToPixelCrop,
makeAspectCrop,
} from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { isImageBlobAGifFile } from "../../utils/isImageBlobAGifFile";
import {
getCroppedImageBlob,
getDarkenedAndResizedImageBlob,
getRotatedImageBlob,
} from "./utils/imageManipulation";
const styleSlider: SxProps<Theme> = {
color: "#7E2AEA",
height: "12px",
"& .MuiSlider-track": {
border: "none",
},
"& .MuiSlider-rail": {
backgroundColor: "#F2F3F7",
border: `1px solid #9A9AAF`,
},
"& .MuiSlider-thumb": {
height: 26,
width: 26,
border: `6px solid #7E2AEA`,
backgroundColor: "white",
boxShadow: `0px 0px 0px 3px white,
0px 4px 4px 3px #C3C8DD`,
"&:focus, &:hover, &.Mui-active, &.Mui-focusVisible": {
boxShadow: `0px 0px 0px 3px white,
0px 4px 4px 3px #C3C8DD`,
},
},
};
interface Props {
isOpen: boolean;
imageBlob: Blob | null;
originalImageUrl: string | null;
setCropModalImageBlob: (imageBlob: Blob) => void;
onClose: () => void;
onSaveImageClick: (imageBlob: Blob) => void;
questionId?: string;
cropAspectRatio?: {
width: number;
height: number;
};
}
export const CropModal: FC<Props> = ({
isOpen,
imageBlob,
originalImageUrl,
setCropModalImageBlob,
onSaveImageClick,
onClose,
questionId,
cropAspectRatio,
}) => {
const theme = useTheme();
const [percentCrop, setPercentCrop] = useState<PercentCrop>();
const [darken, setDarken] = useState(0);
const [imageWidth, setImageWidth] = useState<number | null>(null);
const [imageHeight, setImageHeight] = useState<number | null>(null);
const cropImageElementRef = useRef<HTMLImageElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down(786));
const imageUrl = useMemo(
() => imageBlob && URL.createObjectURL(imageBlob),
[imageBlob],
);
function resetEditState() {
setPercentCrop(undefined);
setDarken(0);
}
async function handleCropClick() {
if (!percentCrop || !imageWidth || !imageHeight) return;
if (!cropImageElementRef.current) throw new Error("No image");
const width = cropImageElementRef.current.width;
const height = cropImageElementRef.current.height;
const pixelCrop = convertToPixelCrop(percentCrop, width, height);
try {
const blob = await getCroppedImageBlob(
cropImageElementRef.current,
pixelCrop,
);
setCropModalImageBlob(blob);
setPercentCrop(undefined);
} catch (error) {
devlog("getCroppedImageBlob error", error);
enqueueSnackbar("Не удалось изменить изображение");
}
}
async function handleRotateClick() {
if (!cropImageElementRef.current) throw new Error("No image");
try {
const blob = await getRotatedImageBlob(cropImageElementRef.current);
setCropModalImageBlob(blob);
setPercentCrop(undefined);
} catch (error) {
devlog("getRotatedImageBlob error", error);
enqueueSnackbar("Не удалось изменить изображение");
}
}
async function handleSaveClick() {
if (!cropImageElementRef.current) throw new Error("No image");
try {
const blob = await getDarkenedAndResizedImageBlob(
cropImageElementRef.current,
1,
darken / 100,
);
onSaveImageClick?.(blob);
resetEditState();
onClose();
} catch (error) {
devlog("getDarkenedImageBlob error", error);
enqueueSnackbar("Не удалось сохранить изображение");
}
}
async function handleLoadOriginalImage() {
if (!originalImageUrl) return;
const response = await fetch(originalImageUrl);
const blob = await response.blob();
setCropModalImageBlob(blob);
resetEditState();
}
function handleSizeChange(value: number) {
setPercentCrop((prev) => {
if (!imageWidth || !imageHeight) return;
const crop = makeAspectCrop(
{
unit: "%",
width: value,
x: 0,
y: 0,
},
cropAspectRatio ? cropAspectRatio.width / cropAspectRatio.height : 1,
imageWidth,
imageHeight,
);
if (!prev || prev.height === 0 || prev.width === 0) {
return centerCrop(crop, imageWidth, imageHeight);
}
crop.x = Math.min(
100 - crop.width,
Math.max(0, prev.x + (prev.width - crop.width) / 2),
);
crop.y = Math.min(
100 - crop.height,
Math.max(0, prev.y + (prev.height - crop.height) / 2),
);
return crop;
});
}
return (
<Modal
open={isOpen}
onClose={() => {
resetEditState();
onClose();
}}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
boxShadow: 24,
padding: "20px",
borderRadius: "8px",
width: isMobile ? "343px" : "620px",
height: isMobile ? "80vh" : undefined,
display: isMobile ? "flex" : undefined,
flexDirection: isMobile ? "column" : undefined,
justifyContent: isMobile ? "flex-start" : undefined,
overflow: isMobile ? "auto" : undefined,
}}
>
<Box
sx={{
height: "320px",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{imageUrl && (
<ReactCrop
crop={percentCrop}
onChange={(_, percentCrop) => setPercentCrop(percentCrop)}
minWidth={5}
minHeight={5}
aspect={
cropAspectRatio
? cropAspectRatio.width / cropAspectRatio.height
: undefined
}
>
<img
onLoad={(e) => {
setImageWidth(e.currentTarget.naturalWidth);
setImageHeight(e.currentTarget.naturalHeight);
}}
ref={cropImageElementRef}
alt="Crop me"
src={imageUrl}
style={{
filter: `brightness(${100 - darken}%)`,
maxWidth: "100%",
maxHeight: "320px",
display: "block",
objectFit: "contain",
}}
/>
</ReactCrop>
)}
</Box>
<Box
sx={{
color: "#7E2AEA",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "16px",
fontWeight: "600",
my: "20px",
}}
>
{cropAspectRatio && (
<Typography sx={{ color: "#7E2AEA" }}>
{`${cropAspectRatio.width} x ${cropAspectRatio.height} px`}
</Typography>
)}
</Box>
<Box
sx={{
display: isMobile ? "block" : "flex",
alignItems: "end",
justifyContent: "space-between",
}}
>
<IconButton onClick={handleRotateClick}>
<ResetIcon />
</IconButton>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Размер
</Typography>
<Slider
sx={[
styleSlider,
{
width: isMobile ? undefined : "200px",
},
]}
value={percentCrop?.width ?? 1}
min={1}
max={100}
step={0.1}
onChange={(_, newValue) => {
if (typeof newValue === "number") handleSizeChange(newValue);
}}
/>
</Box>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Затемнение
</Typography>
<Slider
sx={[
styleSlider,
{
width: isMobile ? undefined : "200px",
},
]}
value={darken}
min={0}
max={100}
step={1}
onChange={(_, newValue) => setDarken(newValue as number)}
/>
</Box>
{questionId !== undefined && (
<IconButton
onClick={() => {
updateQuestion(questionId, (question) => {
question.content.back = null;
question.content.originalBack = null;
});
onClose();
}}
sx={{
height: "48px",
width: "48px",
p: 0,
color: theme.palette.orange.main,
borderRadius: "50%",
}}
>
<DeleteIcon />
</IconButton>
)}
</Box>
<Box
sx={{
marginTop: "40px",
width: "100%",
display: "flex",
gap: "10px",
flexWrap: isMobile ? "wrap" : undefined,
}}
>
<Button
onClick={handleLoadOriginalImage}
disableRipple
disabled={!originalImageUrl}
sx={{
width: "215px",
height: "48px",
color: "#7E2AEA",
borderRadius: "8px",
border: "1px solid #7E2AEA",
}}
>
Загрузить оригинал
</Button>
<Button
onClick={handleCropClick}
disableRipple
sx={{
padding: "10px 20px",
borderRadius: "8px",
background: theme.palette.brightPurple.main,
fontSize: "18px",
color: "#7E2AEA",
border: "1px solid #7E2AEA",
backgroundColor: "transparent",
}}
>
<CropIcon color="#7E2AEA" />
Обрезать
</Button>
<Button
onClick={handleSaveClick}
disableRipple
variant="contained"
data-cy="crop-modal-save-button"
sx={{
height: "48px",
borderRadius: "8px",
border: "1px solid #7E2AEA",
marginRight: "10px",
px: "20px",
ml: "auto",
}}
>
Сохранить
</Button>
</Box>
</Box>
</Modal>
);
};
export function useCropModalState(initialOpenState = false) {
const [isCropModalOpen, setOpened] = useState(initialOpenState);
const [imageBlob, setCropModalImageBlob] = useState<Blob | null>(null);
const [originalImageUrl, setOriginalImageUrl] = useState<string | null>(null);
const closeCropModal = useCallback(() => {
setOpened(false);
setCropModalImageBlob(null);
setOriginalImageUrl(null);
}, []);
const openCropModal = useCallback(
async (
image: Blob | string,
originalImageUrl: string | null | undefined = null,
) => {
if (typeof image === "string") {
const response = await fetch(image);
image = await response.blob();
}
const isGif = await isImageBlobAGifFile(image);
if (isGif) return;
setCropModalImageBlob(image);
setOriginalImageUrl(originalImageUrl);
setOpened(true);
},
[],
);
return {
isCropModalOpen,
openCropModal,
closeCropModal,
imageBlob,
setCropModalImageBlob,
originalImageUrl,
} as const;
}