Merge branch 'dev' into 'staging'

fix crop modal

See merge request frontend/squiz!242
This commit is contained in:
Nastya 2024-04-06 17:53:44 +00:00
commit 4deecc2f66
9 changed files with 109 additions and 114 deletions

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

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

@ -1,5 +1,8 @@
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 { CropModal, useCropModalState } from "@ui_kit/Modal/CropModal";
import { useState } from "react";
@ -120,7 +123,10 @@ export default function OptionsPicture({
setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick}
questionId={question.id}
onDeleteClick={() => {
if (selectedVariantId)
clearQuestionImages(question.id, selectedVariantId);
}}
cropAspectRatio={{ width: 452, height: 300 }}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>

@ -80,6 +80,7 @@ export default function ViewPublicationPage() {
},
}}
quizId={quizId}
preview
/>
</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 = (
questionId: string,
sourceIndex: number,

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

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

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