diff --git a/src/ui_kit/Modal/CropModal2.tsx b/src/ui_kit/Modal/CropModal2.tsx new file mode 100644 index 00000000..807886bf --- /dev/null +++ b/src/ui_kit/Modal/CropModal2.tsx @@ -0,0 +1,277 @@ +import React, { useState, useRef, FC } from "react"; +import ReactCrop, { Crop, PixelCrop } from "react-image-crop"; +import { Box, Button, Modal, Slider, Typography, useMediaQuery, useTheme } from "@mui/material"; + +import { canvasPreview } from "./utils/canvasPreview"; +import { useDebounceEffect } from "./utils/useDebounceEffect"; + +import { ResetIcon } from "@icons/ResetIcon"; + +import "react-image-crop/dist/ReactCrop.css"; + +interface Iprops { + opened: boolean; + onClose: React.Dispatch>; +} + +export const CropModal2: FC = ({ opened, onClose }) => { + const [imgSrc, setImgSrc] = useState(""); + const imgRef = useRef(null); + const fileInputRef = useRef(null); + const previewCanvasRef = useRef(null); + const hiddenAnchorRef = useRef(null); + const blobUrlRef = useRef(""); + const [crop, setCrop] = useState(); + const [completedCrop, setCompletedCrop] = useState(); + const [rotate, setRotate] = useState(0); + const [imageSize, setImageSize] = useState(580); + const [darken, setDarken] = useState(0); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down(786)); + + const style = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: isMobile ? "343px" : "620px", + bgcolor: "background.paper", + boxShadow: 24, + padding: "20px", + borderRadius: "8px", + }; + + const styleSlider = { + width: isMobile ? "350px" : "250px", + 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`, + }, + }, + }; + + const rotateImage = () => { + const newRotation = (rotate + 90) % 360; + setRotate(newRotation); + }; + + const onSelectFile = (event: React.ChangeEvent) => { + if (event.target.files && event.target.files.length > 0) { + setCrop(undefined); + const reader = new FileReader(); + reader.addEventListener("load", () => setImgSrc(reader.result?.toString() || "")); + reader.readAsDataURL(event.target.files[0]); + } + }; + + const onDownloadCropClick = () => { + if (!previewCanvasRef.current) { + throw new Error("Crop canvas does not exist"); + } + + const canvasCopy = document.createElement("canvas"); + const ctx = canvasCopy.getContext("2d"); + canvasCopy.width = previewCanvasRef.current.width; + canvasCopy.height = previewCanvasRef.current.height; + ctx!.filter = `brightness(${100 - darken}%)`; + ctx!.drawImage(previewCanvasRef.current, 0, 0); + + canvasCopy.toBlob((blob) => { + if (!blob) { + throw new Error("Failed to create blob"); + } + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + } + blobUrlRef.current = URL.createObjectURL(blob); + hiddenAnchorRef.current!.href = blobUrlRef.current; + hiddenAnchorRef.current!.click(); + }); + }; + + useDebounceEffect( + async () => { + if (completedCrop?.width && completedCrop?.height && imgRef.current && previewCanvasRef.current) { + canvasPreview(imgRef.current, previewCanvasRef.current, completedCrop, rotate); + } + }, + 100, + [completedCrop, rotate] + ); + + return ( + <> + {completedCrop && ( +
+ +
+ )} + + + + + {!!imgSrc && ( + setCrop(percentCrop)} + onComplete={(c) => setCompletedCrop(c)} + maxWidth={500} + maxHeight={320} + > + Crop me + + )} + + + + + {crop?.width ? Math.round(crop.width) + "px" : ""} + + + {crop?.width ? Math.round(crop.width) + "px" : ""} + + + + + + + Размер + { + setImageSize(newValue as number); + }} + /> + + + Затемнение + setDarken(newValue as number)} + /> + + + + + + + + + + + ); +}; diff --git a/src/ui_kit/Modal/CroppingModal.tsx b/src/ui_kit/Modal/CroppingModal.tsx index 0da3cf5e..9ebbd6ab 100644 --- a/src/ui_kit/Modal/CroppingModal.tsx +++ b/src/ui_kit/Modal/CroppingModal.tsx @@ -17,7 +17,7 @@ export const CroppingModal: FC = ({ opened, onClose }) => { const isMobile = useMediaQuery(theme.breakpoints.down(786)); const style = { - position: "absolute" as "absolute", + position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", @@ -58,12 +58,22 @@ export const CroppingModal: FC = ({ opened, onClose }) => { const [completedCrop, setCompletedCrop] = useState(null); const [imageSize, setImageSize] = useState(580); const [darken, setDarken] = useState(0); - const fileInputRef = useRef(null); + const [croppedImageUrl, setCroppedImageUrl] = useState(null); - console.log(src); + const fileInputRef = useRef(null); const onCropComplete = (crop: Crop) => { setCompletedCrop(crop); + + if (src) { + getCroppedAndDarkenedImg(src as string, crop, "cropped.jpeg", darken, rotation) + .then((croppedImageUrl) => { + setCroppedImageUrl(croppedImageUrl); + }) + .catch((error) => { + console.error("Ошибка при обрезке и затемнении изображения:", error); + }); + } }; const handleFileChange = (e: React.ChangeEvent) => { @@ -71,7 +81,7 @@ export const CroppingModal: FC = ({ opened, onClose }) => { const file = e.target.files[0]; const reader = new FileReader(); reader.onload = (event) => { - if (event.target && event.target.result) { + if (event.target?.result) { setSrc(event.target.result); } }; @@ -81,16 +91,20 @@ export const CroppingModal: FC = ({ opened, onClose }) => { const handleDownloadClick = async () => { if (completedCrop && src) { - const croppedImageUrl = await getCroppedAndDarkenedImg(src, completedCrop, "cropped.jpeg", darken); + const croppedImageUrl = await getCroppedAndDarkenedImg(src, completedCrop, "cropped.jpeg", darken, rotation); + setCroppedImageUrl(croppedImageUrl); saveAs(croppedImageUrl, "cropped-image.jpeg"); } }; + const [widthImg, setWidthImg] = useState(580); + const getCroppedAndDarkenedImg = ( image: string | ArrayBuffer, crop: Crop, fileName: string, - darken: number + darken: number, + rotation: number ): Promise => { const img = new Image(); img.src = image as string; @@ -99,17 +113,13 @@ export const CroppingModal: FC = ({ opened, onClose }) => { let scaleY = 219 / 320; if (img.naturalWidth) { - scaleX = img.naturalWidth / 580; + scaleX = img.naturalWidth / widthImg; } if (img.naturalHeight) { scaleY = img.naturalHeight / 320; } - console.log(scaleX); - - console.log(scaleY); - const canvas = document.createElement("canvas"); canvas.width = crop.width!; canvas.height = crop.height!; @@ -119,19 +129,24 @@ export const CroppingModal: FC = ({ opened, onClose }) => { throw new Error("Canvas context is null"); } + // Применяем вращение к canvas + ctx.translate(crop.width / 2, crop.height / 2); + ctx.rotate((rotation * Math.PI) / 180); + ctx.translate(-crop.width / 2, -crop.height / 2); + ctx.drawImage( img, - crop.x! * scaleX, - crop.y! * scaleY, - crop.width! * scaleX, - crop.height! * scaleY, + crop.x * scaleX, + crop.y * scaleY, + crop.width * scaleX, + crop.height * scaleY, 0, 0, - crop.width!, - crop.height! + crop.width, + crop.height ); - const imageData = ctx.getImageData(0, 0, crop.width!, crop.height!); + const imageData = ctx.getImageData(0, 0, crop.width, crop.height); const newImageData = imageData.data.map((value, index) => { if ((index + 1) % 4 === 0) { @@ -158,136 +173,172 @@ export const CroppingModal: FC = ({ opened, onClose }) => { }); }; + const [rotation, setRotation] = useState(0); + + const rotateImage = () => { + const newRotation = (rotation + 90) % 360; + setRotation(newRotation); + }; + + const handleSliderChange = (newValue: number) => { + setDarken(newValue); + + getCroppedAndDarkenedImg(src as string, completedCrop!, "cropped.jpeg", newValue, rotation); + }; + return ( - - - - setCrop(newCrop)} onComplete={onCropComplete}> - {src && ( - Crop - )} - - - - - {Math.round(crop.width)}x - {Math.round(crop.width)} - px - - - - { - setCrop((prevCrop: Crop) => ({ - ...prevCrop, - unit: "px", - x: 210, - y: 10, - width: 210, - height: 300, - })); - - setDarken(0); - setImageSize(580); - }} - style={{ marginBottom: "10px", cursor: "pointer" }} - /> - - Размер - setImageSize(newValue as number)} - /> - - - Затемнение - setDarken(newValue as number)} - /> - - - - - - + {Math.round(crop.width)}x + {Math.round(crop.width)} + px + + + + { + setCrop((prevCrop: Crop) => ({ + ...prevCrop, + unit: "px", + x: 210, + y: 10, + width: 210, + height: 300, + })); + setRotation(0); + setDarken(0); + setImageSize(580); + }} + style={{ marginBottom: "10px", cursor: "pointer" }} + /> + + Размер + { + setImageSize(newValue as number); + setWidthImg(580 * (imageSize / 200)); + }} + /> + + + Затемнение + handleSliderChange(newValue as number)} + /> + + + + + + + - - + + ); }; diff --git a/src/ui_kit/Modal/ImageCrop.tsx b/src/ui_kit/Modal/ImageCrop.tsx index 5c0c75e0..e630852a 100644 --- a/src/ui_kit/Modal/ImageCrop.tsx +++ b/src/ui_kit/Modal/ImageCrop.tsx @@ -1,13 +1,15 @@ import { Box, Button } from "@mui/material"; import { FC, useState } from "react"; import { CroppingModal } from "./CroppingModal"; +import { CropModal2 } from "./CropModal2"; const ImageCrop: FC = () => { const [opened, setOpened] = useState(false); return ( - setOpened(false)} /> + {/* setOpened(false)} /> */} + setOpened(false)} /> ); }; diff --git a/src/ui_kit/Modal/utils/canvasPreview.ts b/src/ui_kit/Modal/utils/canvasPreview.ts new file mode 100644 index 00000000..02278c37 --- /dev/null +++ b/src/ui_kit/Modal/utils/canvasPreview.ts @@ -0,0 +1,50 @@ +import { PixelCrop } from "react-image-crop"; + +const TO_RADIANS = Math.PI / 180; + +export async function canvasPreview(image: HTMLImageElement, canvas: HTMLCanvasElement, crop: PixelCrop, rotate = 0) { + const scale = 1; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("No 2d context"); + } + + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + // devicePixelRatio slightly increases sharpness on retina devices + // at the expense of slightly slower render times and needing to + // size the image back down if you want to download/upload and be + // true to the images natural size. + const pixelRatio = window.devicePixelRatio; + // const pixelRatio = 1 + + 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 rotateRads = rotate * TO_RADIANS; + const centerX = image.naturalWidth / 2; + const centerY = image.naturalHeight / 2; + + ctx.save(); + + // 5) Move the crop origin to the canvas origin (0,0) + ctx.translate(-cropX, -cropY); + // 4) Move the origin to the center of the original position + ctx.translate(centerX, centerY); + // 3) Rotate around the origin + ctx.rotate(rotateRads); + // 2) Scale the image + ctx.scale(scale, scale); + // 1) Move the center of the image to the origin (0,0) + ctx.translate(-centerX, -centerY); + ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight, 0, 0, image.naturalWidth, image.naturalHeight); + + ctx.restore(); +} diff --git a/src/ui_kit/Modal/utils/useDebounceEffect.ts b/src/ui_kit/Modal/utils/useDebounceEffect.ts new file mode 100644 index 00000000..8b4bca50 --- /dev/null +++ b/src/ui_kit/Modal/utils/useDebounceEffect.ts @@ -0,0 +1,13 @@ +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]); +}