Merge branch 'dev' into 'staging'
fix crop modal See merge request frontend/squiz!242
This commit is contained in:
commit
4deecc2f66
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user