frontPanel/src/ui_kit/Modal/CropModal.tsx

427 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 {
Box,
Button,
IconButton,
Modal,
Slider,
SxProps,
Theme,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { enqueueSnackbar } from "notistack";
import { FC, useCallback, useMemo, useRef, useState } from "react";
import ReactCrop, { PercentCrop, PixelCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { isImageBlobAGifFile } from "../../utils/isImageBlobAGifFile";
import {
getCroppedImageBlob,
getDarkenedAndResizedImageBlob,
getRotatedImageBlob,
} from "./utils/imageManipulation";
import DeleteIcon from "@mui/icons-material/Delete";
import { deleteQuestion, updateQuestion } from "@root/questions/actions";
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;
}
export const CropModal: FC<Props> = ({
isOpen,
imageBlob,
originalImageUrl,
setCropModalImageBlob,
onSaveImageClick,
onClose,
questionId
}) => {
const theme = useTheme();
const [percentCrop, setPercentCrop] = useState<PercentCrop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [darken, setDarken] = useState(0);
const [scale, setScale] = useState<number>(1);
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);
setCompletedCrop(undefined);
setDarken(0);
setScale(1);
}
async function handleCropClick() {
if (!cropImageElementRef.current) throw new Error("No image");
if (!completedCrop) return;
try {
const blob = await getCroppedImageBlob(
cropImageElementRef.current,
completedCrop,
);
setCropModalImageBlob(blob);
setPercentCrop(undefined);
setCompletedCrop(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);
setCompletedCrop(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,
scale,
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();
}
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)}
onComplete={(c) => setCompletedCrop(c)}
minWidth={50}
minHeight={50}
>
<img
onLoad={(e) => {
setImageWidth(e.currentTarget.naturalWidth);
setImageHeight(e.currentTarget.naturalHeight);
}}
ref={cropImageElementRef}
alt="Crop me"
src={imageUrl}
style={{
filter: `brightness(${100 - darken}%)`,
width: imageWidth ? imageWidth * scale : undefined,
height: "100%",
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",
}}
>
{imageWidth &&
imageHeight &&
(percentCrop?.height && percentCrop?.width ? (
<Typography sx={{ color: "#7E2AEA" }}>
{`${Math.round(
(percentCrop.width / 100) * imageWidth * scale,
)} x ${Math.round(
(percentCrop.height / 100) * imageHeight * scale,
)} px`}
</Typography>
) : (
<Typography sx={{ color: "#7E2AEA" }}>
{`${Math.round(imageWidth * scale)} x ${Math.round(
imageHeight * scale,
)} 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={scale * 100}
min={1}
max={200}
step={1}
onChange={(_, newValue) => {
setScale((newValue as number) * 0.01);
}}
/>
</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
disabled={!completedCrop}
sx={{
padding: "10px 20px",
borderRadius: "8px",
background: theme.palette.brightPurple.main,
fontSize: "18px",
color: "#7E2AEA",
border: `1px solid ${!completedCrop ? "rgba(0, 0, 0, 0.26)" : "#7E2AEA"
}`,
backgroundColor: "transparent",
}}
>
<CropIcon
color={!completedCrop ? "rgba(0, 0, 0, 0.26)" : "#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;
}