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