fix crop modal image manipulations

This commit is contained in:
nflnkr 2023-12-16 20:04:05 +03:00
parent 9706b7febf
commit 26ef2c1321
4 changed files with 196 additions and 19132 deletions

19013
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,3 +1,4 @@
import { devlog } from "@frontend/kitui";
import { CropIcon } from "@icons/CropIcon"; import { CropIcon } from "@icons/CropIcon";
import { ResetIcon } from "@icons/ResetIcon"; import { ResetIcon } from "@icons/ResetIcon";
import { import {
@ -12,10 +13,11 @@ import {
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { FC, useMemo, useRef, useState } from "react"; import { enqueueSnackbar } from "notistack";
import ReactCrop, { Crop, PixelCrop } from "react-image-crop"; import { FC, useEffect, useMemo, useRef, useState } from "react";
import ReactCrop, { PercentCrop, PixelCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css"; import "react-image-crop/dist/ReactCrop.css";
import { canvasPreview } from "./utils/canvasPreview"; import { getCroppedImageBlob, getDarkenedAndResizedImageBlob, getRotatedImageBlob } from "./utils/imageManipulation";
const styleSlider: SxProps<Theme> = { const styleSlider: SxProps<Theme> = {
@ -53,44 +55,67 @@ interface Props {
export const CropModal: FC<Props> = ({ isOpen, imageBlob, originalImageUrl, setCropModalImageBlob, onSaveImageClick, onClose }) => { export const CropModal: FC<Props> = ({ isOpen, imageBlob, originalImageUrl, setCropModalImageBlob, onSaveImageClick, onClose }) => {
const theme = useTheme(); const theme = useTheme();
const [crop, setCrop] = useState<Crop>(); const [percentCrop, setPercentCrop] = useState<PercentCrop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>(); const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [darken, setDarken] = useState(0); const [darken, setDarken] = useState(0);
const [rotate, setRotate] = useState(0); const [scale, setScale] = useState<number>(1);
const [width, setWidth] = useState<number>(240); const [imageWidth, setImageWidth] = useState<number | null>(null);
const [imageHeight, setImageHeight] = useState<number | null>(null);
const cropImageElementRef = useRef<HTMLImageElement>(null); const cropImageElementRef = useRef<HTMLImageElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down(786)); const isMobile = useMediaQuery(theme.breakpoints.down(786));
const imageUrl = useMemo(() => imageBlob && URL.createObjectURL(imageBlob), [imageBlob]); const imageUrl = useMemo(() => imageBlob && URL.createObjectURL(imageBlob), [imageBlob]);
const handleCropClick = async () => { function resetEditState() {
if (!completedCrop) throw new Error("No completed crop"); setPercentCrop(undefined);
setCompletedCrop(undefined);
setDarken(0);
setScale(1);
}
async function handleCropClick() {
if (!cropImageElementRef.current) throw new Error("No image"); if (!cropImageElementRef.current) throw new Error("No image");
if (!completedCrop) return;
const canvasCopy = document.createElement("canvas"); try {
const ctx = canvasCopy.getContext("2d"); const blob = await getCroppedImageBlob(cropImageElementRef.current, completedCrop);
if (!ctx) throw new Error("No 2d context");
canvasCopy.width = completedCrop.width;
canvasCopy.height = completedCrop.height;
ctx.filter = `brightness(${100 - darken}%)`;
await canvasPreview(cropImageElementRef.current, canvasCopy, completedCrop, rotate);
canvasCopy.toBlob((blob) => {
if (!blob) throw new Error("Failed to create blob");
setCropModalImageBlob(blob); setCropModalImageBlob(blob);
setCrop(undefined); setPercentCrop(undefined);
setCompletedCrop(undefined); setCompletedCrop(undefined);
}); } catch (error) {
devlog("getCroppedImageBlob error", error);
enqueueSnackbar("Не удалось изменить изображение");
}
}; };
function handleSaveClick() { async function handleRotateClick() {
if (imageBlob) onSaveImageClick?.(imageBlob); if (!cropImageElementRef.current) throw new Error("No image");
setCrop(undefined);
setCompletedCrop(undefined); try {
onClose(); 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() { async function handleLoadOriginalImage() {
@ -100,33 +125,16 @@ export const CropModal: FC<Props> = ({ isOpen, imageBlob, originalImageUrl, setC
const blob = await response.blob(); const blob = await response.blob();
setCropModalImageBlob(blob); setCropModalImageBlob(blob);
setCrop(undefined); resetEditState();
setCompletedCrop(undefined);
} }
const getImageSize = () => {
if (cropImageElementRef.current) {
const imageWidth = cropImageElementRef.current.naturalWidth;
const imageHeight = cropImageElementRef.current.naturalHeight;
const aspect = imageWidth / imageHeight;
if (aspect <= 1.333) {
setWidth(240);
}
if (aspect >= 1.5) {
setWidth(580);
}
if (aspect >= 1.778) {
setWidth(580);
}
}
};
return ( return (
<Modal <Modal
open={isOpen} open={isOpen}
onClose={onClose} onClose={() => {
resetEditState();
onClose();
}}
aria-labelledby="modal-modal-title" aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description" aria-describedby="modal-modal-description"
> >
@ -141,70 +149,67 @@ export const CropModal: FC<Props> = ({ isOpen, imageBlob, originalImageUrl, setC
borderRadius: "8px", borderRadius: "8px",
width: isMobile ? "343px" : "620px", width: isMobile ? "343px" : "620px",
}}> }}>
<Box <Box sx={{
sx={{ height: "320px",
height: "320px", backgroundSize: "cover",
padding: "10px", backgroundRepeat: "no-repeat",
backgroundSize: "cover", display: "flex",
backgroundRepeat: "no-repeat", alignItems: "center",
display: "flex", justifyContent: "center",
alignItems: "center", }}>
justifyContent: "center",
}}
>
{imageUrl && ( {imageUrl && (
<ReactCrop <ReactCrop
crop={crop} crop={percentCrop}
onChange={(_, percentCrop) => setCrop(percentCrop)} onChange={(_, percentCrop) => setPercentCrop(percentCrop)}
onComplete={(c) => setCompletedCrop(c)} onComplete={(c) => setCompletedCrop(c)}
maxWidth={500}
minWidth={50} minWidth={50}
maxHeight={320}
minHeight={50} minHeight={50}
> >
<img <img
onLoad={getImageSize} onLoad={e => {
setImageWidth(e.currentTarget.naturalWidth);
setImageHeight(e.currentTarget.naturalHeight);
}}
ref={cropImageElementRef} ref={cropImageElementRef}
alt="Crop me" alt="Crop me"
src={imageUrl} src={imageUrl}
style={{ style={{
filter: `brightness(${100 - darken}%)`, filter: `brightness(${100 - darken}%)`,
transform: ` rotate(${rotate}deg)`, width: imageWidth ? imageWidth * scale : undefined,
maxWidth: "580px", height: "100%",
maxWidth: "100%",
maxHeight: "320px", maxHeight: "320px",
display: "block",
objectFit: "contain",
}} }}
width={width}
/> />
</ReactCrop> </ReactCrop>
)} )}
</Box> </Box>
<Box <Box sx={{
sx={{ color: "#7E2AEA",
color: "#7E2AEA", display: "flex",
display: "flex", alignItems: "center",
alignItems: "center", justifyContent: "center",
justifyContent: "center", fontSize: "16px",
fontSize: "16xp", fontWeight: "600",
fontWeight: "600", my: "20px",
marginBottom: "50px", }}>
}} {imageWidth && imageHeight && ((percentCrop?.height && percentCrop?.width)
> ? <Typography sx={{ color: "#7E2AEA" }}>
<Typography sx={{ color: "#7E2AEA", lineHeight: "0px" }}> {`${Math.round(percentCrop.width / 100 * imageWidth * scale)} x ${Math.round(percentCrop.height / 100 * imageHeight * scale)} px`}
{crop?.width ? Math.round(crop.width) + "px" : ""} </Typography>
</Typography> : <Typography sx={{ color: "#7E2AEA" }}>
<Typography sx={{ color: "#7E2AEA", lineHeight: "0px" }}> {`${Math.round(imageWidth * scale)} x ${Math.round(imageHeight * scale)} px`}
{crop?.height ? Math.round(crop.height) + "px" : ""} </Typography>
</Typography> )}
</Box> </Box>
<Box sx={{
<Box display: isMobile ? "block" : "flex",
sx={{ alignItems: "end",
display: isMobile ? "block" : "flex", justifyContent: "space-between",
alignItems: "end", }}>
justifyContent: "space-between", <IconButton onClick={handleRotateClick}>
}}
>
<IconButton onClick={() => setRotate(r => (r + 90) % 360)}>
<ResetIcon /> <ResetIcon />
</IconButton> </IconButton>
<Box> <Box>
@ -215,12 +220,12 @@ export const CropModal: FC<Props> = ({ isOpen, imageBlob, originalImageUrl, setC
sx={[styleSlider, { sx={[styleSlider, {
width: isMobile ? "350px" : "250px", width: isMobile ? "350px" : "250px",
}]} }]}
value={width} value={scale * 100}
min={50} min={1}
max={580} max={200}
step={1} step={1}
onChange={(_, newValue) => { onChange={(_, newValue) => {
setWidth(newValue as number); setScale((newValue as number) * 0.01);
}} }}
/> />
</Box> </Box>
@ -240,13 +245,11 @@ export const CropModal: FC<Props> = ({ isOpen, imageBlob, originalImageUrl, setC
/> />
</Box> </Box>
</Box> </Box>
<Box <Box sx={{
sx={{ marginTop: "40px",
marginTop: "40px", width: "100%",
width: "100%", display: "flex",
display: "flex", }}>
}}
>
<Button <Button
onClick={handleSaveClick} onClick={handleSaveClick}
disableRipple disableRipple
@ -280,7 +283,7 @@ export const CropModal: FC<Props> = ({ isOpen, imageBlob, originalImageUrl, setC
onClick={handleCropClick} onClick={handleCropClick}
disableRipple disableRipple
variant="contained" variant="contained"
disabled={!completedCrop} disabled={!completedCrop?.width || !completedCrop?.height}
sx={{ sx={{
padding: "10px 20px", padding: "10px 20px",
borderRadius: "8px", borderRadius: "8px",

@ -0,0 +1,87 @@
import { PixelCrop } from "react-image-crop";
export function getRotatedImageBlob(image: HTMLImageElement) {
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("No 2d context"));
canvas.width = image.naturalHeight;
canvas.height = image.naturalWidth;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(Math.PI / 2);
ctx.drawImage(image, -canvas.height / 2, -canvas.width / 2);
canvas.toBlob((blob) => {
if (!blob) return reject(new Error("Failed to create blob"));
resolve(blob);
});
});
}
export function getCroppedImageBlob(image: HTMLImageElement, crop: PixelCrop) {
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("No 2d context"));
const scale = 1;
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const pixelRatio = window.devicePixelRatio;
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
ctx.scale(pixelRatio, pixelRatio);
ctx.imageSmoothingQuality = "high";
const cropX = crop.x * scaleX;
const cropY = crop.y * scaleY;
const centerX = image.naturalWidth / 2;
const centerY = image.naturalHeight / 2;
ctx.translate(-cropX, -cropY);
ctx.translate(centerX, centerY);
ctx.scale(scale, scale);
ctx.translate(-centerX, -centerY);
ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight, 0, 0, image.naturalWidth, image.naturalHeight);
canvas.toBlob((blob) => {
if (!blob) return reject(new Error("Failed to create blob"));
resolve(blob);
});
});
}
export function getDarkenedAndResizedImageBlob(image: HTMLImageElement, scale: number, darken: number) {
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("No 2d context"));
const width = Math.floor(image.naturalWidth * scale);
const height = Math.floor(image.naturalHeight * scale);
canvas.width = width;
canvas.height = height;
ctx.drawImage(image, 0, 0, width, height);
if (darken > 0) {
ctx.fillStyle = `rgba(0, 0, 0, ${darken})`;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
canvas.toBlob((blob) => {
if (!blob) return reject(new Error("Failed to create blob"));
resolve(blob);
});
});
}

@ -1,13 +0,0 @@
import { useEffect, DependencyList } from "react";
export function useDebounceEffect(fn: () => void, waitTime: number, deps?: DependencyList) {
useEffect(() => {
const time = setTimeout(() => {
fn();
}, waitTime);
return () => {
clearTimeout(time);
};
}, [deps, fn, waitTime]);
}