fix crop modal

This commit is contained in:
nflnkr 2024-04-06 20:23:01 +03:00
parent 859987445b
commit e77e83d5d4
9 changed files with 109 additions and 114 deletions

@ -7,7 +7,7 @@
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@frontend/kitui": "^1.0.74", "@frontend/kitui": "^1.0.74",
"@frontend/squzanswerer": "^1.0.17", "@frontend/squzanswerer": "^1.0.19",
"@mui/icons-material": "^5.10.14", "@mui/icons-material": "^5.10.14",
"@mui/material": "^5.10.14", "@mui/material": "^5.10.14",
"@mui/x-charts": "^6.19.5", "@mui/x-charts": "^6.19.5",

@ -1,6 +1,7 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { import {
addQuestionVariant, addQuestionVariant,
clearQuestionImages,
uploadQuestionImage, uploadQuestionImage,
} from "@root/questions/actions"; } from "@root/questions/actions";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { useCurrentQuiz } from "@root/quizes/hooks";
@ -125,7 +126,10 @@ export default function OptionsAndPicture({
setCropModalImageBlob={setCropModalImageBlob} setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal} onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick} onSaveImageClick={handleCropModalSaveClick}
questionId={question.id} onDeleteClick={() => {
if (selectedVariantId)
clearQuestionImages(question.id, selectedVariantId);
}}
cropAspectRatio={{ width: 452, height: 300 }} cropAspectRatio={{ width: 452, height: 300 }}
/> />
<Box <Box

@ -1,5 +1,8 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { uploadQuestionImage } from "@root/questions/actions"; import {
clearQuestionImages,
uploadQuestionImage,
} from "@root/questions/actions";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { useCurrentQuiz } from "@root/quizes/hooks";
import { CropModal, useCropModalState } from "@ui_kit/Modal/CropModal"; import { CropModal, useCropModalState } from "@ui_kit/Modal/CropModal";
import { useState } from "react"; import { useState } from "react";
@ -120,7 +123,10 @@ export default function OptionsPicture({
setCropModalImageBlob={setCropModalImageBlob} setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal} onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick} onSaveImageClick={handleCropModalSaveClick}
questionId={question.id} onDeleteClick={() => {
if (selectedVariantId)
clearQuestionImages(question.id, selectedVariantId);
}}
cropAspectRatio={{ width: 452, height: 300 }} cropAspectRatio={{ width: 452, height: 300 }}
/> />
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}> <Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>

@ -80,6 +80,7 @@ export default function ViewPublicationPage() {
}, },
}} }}
quizId={quizId} quizId={quizId}
preview
/> />
</Box> </Box>
); );

@ -370,6 +370,21 @@ export const setQuestionVariantField = (
}); });
}; };
export const clearQuestionImages = (questionId: string, variantId: string) => {
updateQuestion(questionId, (question) => {
if (!("variants" in question.content)) return;
const variantIndex = question.content.variants.findIndex(
(variant) => variant.id === variantId,
);
if (variantIndex === -1) return;
const variant = question.content.variants[variantIndex];
variant.extendedText = "";
variant.originalImageUrl = "";
});
};
export const reorderQuestionVariants = ( export const reorderQuestionVariants = (
questionId: string, questionId: string,
sourceIndex: number, sourceIndex: number,

@ -139,7 +139,12 @@ export const MediaSelectionAndDisplay: FC<Iprops> = ({
setCropModalImageBlob={setCropModalImageBlob} setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal} onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick} onSaveImageClick={handleCropModalSaveClick}
questionId={resultData.id} onDeleteClick={() => {
updateQuestion(resultData.id, (question) => {
question.content.back = null;
question.content.originalBack = null;
});
}}
cropAspectRatio={cropAspectRatio} cropAspectRatio={cropAspectRatio}
/> />
</Box> </Box>

@ -1,5 +1,4 @@
import { devlog } from "@frontend/kitui"; import { devlog } from "@frontend/kitui";
import { CropIcon } from "@icons/CropIcon";
import { ResetIcon } from "@icons/ResetIcon"; import { ResetIcon } from "@icons/ResetIcon";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { import {
@ -14,7 +13,6 @@ import {
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { updateQuestion } from "@root/questions/actions";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { FC, useCallback, useMemo, useRef, useState } from "react"; import { FC, useCallback, useMemo, useRef, useState } from "react";
import ReactCrop, { import ReactCrop, {
@ -26,8 +24,7 @@ import ReactCrop, {
import "react-image-crop/dist/ReactCrop.css"; import "react-image-crop/dist/ReactCrop.css";
import { isImageBlobAGifFile } from "../../utils/isImageBlobAGifFile"; import { isImageBlobAGifFile } from "../../utils/isImageBlobAGifFile";
import { import {
getCroppedImageBlob, getModifiedImageBlob,
getDarkenedAndResizedImageBlob,
getRotatedImageBlob, getRotatedImageBlob,
} from "./utils/imageManipulation"; } from "./utils/imageManipulation";
@ -62,6 +59,7 @@ interface Props {
setCropModalImageBlob: (imageBlob: Blob) => void; setCropModalImageBlob: (imageBlob: Blob) => void;
onClose: () => void; onClose: () => void;
onSaveImageClick: (imageBlob: Blob) => void; onSaveImageClick: (imageBlob: Blob) => void;
onDeleteClick?: () => void;
questionId?: string; questionId?: string;
cropAspectRatio?: { cropAspectRatio?: {
width: number; width: number;
@ -75,12 +73,15 @@ export const CropModal: FC<Props> = ({
originalImageUrl, originalImageUrl,
setCropModalImageBlob, setCropModalImageBlob,
onSaveImageClick, onSaveImageClick,
onDeleteClick,
onClose, onClose,
questionId, questionId,
cropAspectRatio, cropAspectRatio,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const [percentCrop, setPercentCrop] = useState<PercentCrop>(); const [percentCrop, setPercentCrop] = useState<PercentCrop | undefined>(
undefined,
);
const [darken, setDarken] = useState(0); const [darken, setDarken] = useState(0);
const [imageWidth, setImageWidth] = useState<number | null>(null); const [imageWidth, setImageWidth] = useState<number | null>(null);
const [imageHeight, setImageHeight] = useState<number | null>(null); const [imageHeight, setImageHeight] = useState<number | null>(null);
@ -97,22 +98,24 @@ export const CropModal: FC<Props> = ({
setDarken(0); setDarken(0);
} }
async function handleCropClick() { async function handleSaveModifiedImage() {
if (!percentCrop || !imageWidth || !imageHeight) return; if (!percentCrop || !imageWidth || !imageHeight) return;
if (!cropImageElementRef.current) throw new Error("No image"); if (!cropImageElementRef.current) throw new Error("No image");
const width = cropImageElementRef.current.width; const width = cropImageElementRef.current.width;
const height = cropImageElementRef.current.height; const height = cropImageElementRef.current.height;
const pixelCrop = convertToPixelCrop(percentCrop, width, height); const pixelCrop = convertToPixelCrop(percentCrop, width, height);
try { try {
const blob = await getCroppedImageBlob( const blob = await getModifiedImageBlob(
cropImageElementRef.current, cropImageElementRef.current,
pixelCrop, pixelCrop,
darken,
); );
setCropModalImageBlob(blob); onSaveImageClick?.(blob);
setPercentCrop(undefined); resetEditState();
onClose();
} catch (error) { } catch (error) {
devlog("getCroppedImageBlob error", error); devlog("getCroppedImageBlob error", error);
enqueueSnackbar("Не удалось изменить изображение"); enqueueSnackbar("Не удалось изменить изображение");
@ -133,32 +136,15 @@ export const CropModal: FC<Props> = ({
} }
} }
async function handleSaveClick() { async function handleSaveOriginalImage() {
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; if (!originalImageUrl) return;
const response = await fetch(originalImageUrl); const response = await fetch(originalImageUrl);
const blob = await response.blob(); const blob = await response.blob();
setCropModalImageBlob(blob); onSaveImageClick?.(blob);
resetEditState(); resetEditState();
onClose();
} }
function handleSizeChange(value: number) { function handleSizeChange(value: number) {
@ -248,6 +234,18 @@ export const CropModal: FC<Props> = ({
onLoad={(e) => { onLoad={(e) => {
setImageWidth(e.currentTarget.naturalWidth); setImageWidth(e.currentTarget.naturalWidth);
setImageHeight(e.currentTarget.naturalHeight); setImageHeight(e.currentTarget.naturalHeight);
if (cropImageElementRef.current) {
setPercentCrop(
getInitialCrop(
cropImageElementRef.current.width,
cropImageElementRef.current.height,
cropAspectRatio
? cropAspectRatio.width / cropAspectRatio.height
: 1,
),
);
}
}} }}
ref={cropImageElementRef} ref={cropImageElementRef}
alt="Crop me" alt="Crop me"
@ -265,23 +263,7 @@ export const CropModal: FC<Props> = ({
</Box> </Box>
<Box <Box
sx={{ sx={{
color: "#7E2AEA", mt: "40px",
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", display: isMobile ? "block" : "flex",
alignItems: "end", alignItems: "end",
justifyContent: "space-between", justifyContent: "space-between",
@ -328,13 +310,10 @@ export const CropModal: FC<Props> = ({
onChange={(_, newValue) => setDarken(newValue as number)} onChange={(_, newValue) => setDarken(newValue as number)}
/> />
</Box> </Box>
{questionId !== undefined && ( {onDeleteClick && (
<IconButton <IconButton
onClick={() => { onClick={() => {
updateQuestion(questionId, (question) => { onDeleteClick?.();
question.content.back = null;
question.content.originalBack = null;
});
onClose(); onClose();
}} }}
sx={{ sx={{
@ -359,41 +338,22 @@ export const CropModal: FC<Props> = ({
}} }}
> >
<Button <Button
onClick={handleLoadOriginalImage} onClick={handleSaveOriginalImage}
disableRipple disableRipple
disabled={!originalImageUrl}
sx={{ sx={{
width: "215px",
height: "48px", height: "48px",
color: "#7E2AEA", color: "#7E2AEA",
borderRadius: "8px", borderRadius: "8px",
border: "1px solid #7E2AEA", border: "1px solid #7E2AEA",
px: "20px",
}} }}
> >
Загрузить оригинал Сохранить оригинал
</Button> </Button>
<Button <Button
onClick={handleCropClick} onClick={handleSaveModifiedImage}
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 disableRipple
variant="contained" variant="contained"
data-cy="crop-modal-save-button"
sx={{ sx={{
height: "48px", height: "48px",
borderRadius: "8px", borderRadius: "8px",
@ -403,7 +363,7 @@ export const CropModal: FC<Props> = ({
ml: "auto", ml: "auto",
}} }}
> >
Сохранить Сохранить редактированное
</Button> </Button>
</Box> </Box>
</Box> </Box>
@ -451,3 +411,29 @@ export function useCropModalState(initialOpenState = false) {
originalImageUrl, originalImageUrl,
} as const; } as const;
} }
function getInitialCrop(
imageWidth: number,
imageHeight: number,
aspectRatio: number,
): PercentCrop {
const imageAspectRatio = imageWidth / imageHeight;
return centerCrop(
{
width:
imageAspectRatio < aspectRatio
? 100
: (100 * aspectRatio) / imageAspectRatio,
height:
imageAspectRatio < aspectRatio
? (100 * imageAspectRatio) / aspectRatio
: 100,
unit: "%",
x: 0,
y: 0,
},
imageWidth,
imageHeight,
);
}

@ -21,7 +21,11 @@ export function getRotatedImageBlob(image: HTMLImageElement) {
}); });
} }
export function getCroppedImageBlob(image: HTMLImageElement, crop: PixelCrop) { export function getModifiedImageBlob(
image: HTMLImageElement,
crop: PixelCrop,
darken: number,
) {
return new Promise<Blob>((resolve, reject) => { return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
@ -60,35 +64,9 @@ export function getCroppedImageBlob(image: HTMLImageElement, crop: PixelCrop) {
image.naturalHeight, 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) { if (darken > 0) {
ctx.fillStyle = `rgba(0, 0, 0, ${darken})`; ctx.fillStyle = `rgba(0, 0, 0, ${darken / 100})`;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.fillRect(0, 0, image.naturalWidth, image.naturalHeight);
} }
canvas.toBlob((blob) => { canvas.toBlob((blob) => {

@ -1517,10 +1517,10 @@
immer "^10.0.2" immer "^10.0.2"
reconnecting-eventsource "^1.6.2" reconnecting-eventsource "^1.6.2"
"@frontend/squzanswerer@^1.0.17": "@frontend/squzanswerer@^1.0.19":
version "1.0.18" version "1.0.19"
resolved "https://penahub.gitlab.yandexcloud.net/api/v4/projects/43/packages/npm/@frontend/squzanswerer/-/@frontend/squzanswerer-1.0.18.tgz#9a191317ccf7b396af4e85e8c9f1f52cc5243f96" resolved "https://penahub.gitlab.yandexcloud.net/api/v4/projects/43/packages/npm/@frontend/squzanswerer/-/@frontend/squzanswerer-1.0.19.tgz#d04b862073bcb9f7b6b0d229684bbceb920614e4"
integrity sha1-mhkTF8z3s5avToXoyfH1LMUkP5Y= integrity sha1-0EuGIHO8ufe2sNIpaEu865IGFOQ=
dependencies: dependencies:
bowser "1.9.4" bowser "1.9.4"
country-flag-emoji-polyfill "^0.1.8" country-flag-emoji-polyfill "^0.1.8"