Merge branch 'new-crop-modal' into dev

This commit is contained in:
Nastya 2024-11-17 13:21:28 +03:00
commit 6df10d1f03
28 changed files with 1560 additions and 691 deletions

@ -34,7 +34,7 @@ export const register = async (
return [registerResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
console.log(error)
console.error(error)
return [null, `Не удалось зарегестрировать аккаунт. ${error}`];
}

@ -0,0 +1,26 @@
import { Box } from "@mui/material";
interface Props {
color?: string;
height?: string;
width?: string;
}
export default function AmoTrash({ color, height, width }: Props) {
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.4994 6H4.5" stroke="#FC2012" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 18.7492V8.74609H18.5V18.7492C18.5 20.1299 17.3807 21.2492 16 21.2492H8C6.61929 21.2492 5.5 20.1299 5.5 18.7492Z" fill="#FC2012" stroke="#F02B2B"/>
<path d="M15.75 6V4.5C15.75 4.10218 15.592 3.72064 15.3107 3.43934C15.0294 3.15804 14.6478 3 14.25 3H9.75C9.35218 3 8.97064 3.15804 8.68934 3.43934C8.40804 3.72064 8.25 4.10218 8.25 4.5V6" stroke="#FC2012" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</Box>
);
}

@ -0,0 +1,166 @@
import type { PercentCrop } from "react-image-crop";
export const workSpaceTypesList = {
images: {
desktop: {
step: "desktop",
ratio: {
width: 317,
height: 257
}
},
tablet: {
step: "tablet",
ratio: {
width: 455,
height: 257
}
},
mobile: {
step: "mobile",
ratio: {
width: 160,
height: 183
}
},
},
varimg: {
desktop: {
step: "desktop",
ratio: {
width: 450,
height: 450
}
},
mobile: {
step: "mobile",
ratio: {
width: 335,
height: 335,
}
},
},
text: {
desktop: {
step: "desktop",
ratio: {
width: 450,
height: 450
}
},
mobile: {
step: "mobile",
ratio: {
width: 335,
height: 335,
}
},
},
variant: {
desktop: {
step: "desktop",
ratio: {
width: 450,
height: 450
}
},
mobile: {
step: "mobile",
ratio: {
width: 335,
height: 335,
}
},
},
result: {
desktop: {
step: "desktop",
ratio: {
width: 700,
height: 306
}
},
mobile: {
step: "mobile",
ratio: {
width: 335,
height: 236
}
},
}
} as const;
export type WorkSpaceTypesList = typeof workSpaceTypesList;
export type Writable<T> = { -readonly [K in keyof T]: T[K] };
export type CropOnOpenType = {
open: boolean;
originalImageUrl?:string;
imageBlob?: Blob;
editedUrlImagesList?: Record<Partial<ScreenStepsTypes>, string>;
questionId: string;
questionType: AcceptedQuestionTypes;
quizId: string;
variantId?: string;
selfClose: () => void;
setPictureUploading: (is: boolean) => void;
}
export type WorkSpaceTypes = WorkSpaceTypesList[AcceptedQuestionTypes];
export interface CropModalProps {
open: boolean;
editedImages: Record<keyof WorkSpaceTypes, EditedImage>;
workSpaceTypes: WorkSpaceTypes;
originalImageUrl: string;
setEditedImages: (callback: (editedImages: Record<keyof WorkSpaceTypes, EditedImage>) => Record<keyof WorkSpaceTypes, EditedImage>) => void;
onSaveImageClick: () => void;
closeCropModal: CropOnCloseType;
onDeleteClick: () => void;
};
export type AcceptedQuestionTypes = keyof WorkSpaceTypesList; //"images" | "varimg" | "text" | "variant" | "result"
export type CropOnCloseType = () => void;
export type CropOnDeleteIamgeClick = (callback: () => void) => void;
export type EditedImages = Record<Partial<ScreenStepsTypes>, EditedImage>
export type EditedImage = {
step: string,
url: string,
newRules: EditedImageNewRules
}
export type WorkSpaceModel = {
step: Partial<ScreenStepsTypes>,
ratio: CropAspectRatio
};
export type CropAspectRatio = {
width: number;
height: number;
};
export type ScreenStepsTypes = "desktop" | "tablet" | "mobile" | "small";
export type EditedImageNewRules = {
crop: PercentCrop,
darken: number,
rotate: number,
}
export const DEFAULTCROPRULES = {
crop: {
x: 0,
y: 0,
width: 0,
height: 0,
unit: "%" as "%",
},
rotate: 0,
darken: 0,
}

@ -224,8 +224,6 @@ export const SwitchPages = ({
}
body.FieldsRule = FieldsRule;
console.log(body)
if (firstRules) {
setIntegrationRules(quiz.backendId.toString(), body);
} else {

@ -38,7 +38,6 @@ const AnswerItem = memo<AnswerItemProps>(
const setOwnPlaceholder = (replText: string) => {
updateQuestion(questionId, (question) => {
question.content.ownPlaceholder = replText;
console.log(question)
});
};

@ -35,8 +35,6 @@ function CsComponent() {
const modalQuestionParentContentId = useUiTools((state) => state.modalQuestionParentContentId);
const modalQuestionTargetContentId = useUiTools((state) => state.modalQuestionTargetContentId);
const trashQuestions = useQuestionsStore((state) => state.questions);
console.log("trashQuestions")
console.log(trashQuestions)
const cyRef = useRef<Core | null>(null);
const { removeNode } = useRemoveNode({ cyRef });

@ -1,16 +1,22 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { addQuestionVariant, clearQuestionImages, uploadQuestionImage } from "@root/questions/actions";
import {
addQuestionVariant,
clearQuestionImages,
uploadQuestionImage,
} from "@root/questions/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { CropModal, useCropModalState } from "@ui_kit/Modal/CropModal";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon";
import type { QuizQuestionVarImg } from "@frontend/squzanswerer";
import type { QuizQuestionVarImg } from "@frontend/squzanswerer/dist-package/model/questionTypes/varimg";
import { useDisclosure } from "@/utils/useDisclosure";
import { AnswerDraggableList } from "../../AnswerDraggableList";
import ImageEditAnswerItem from "../../AnswerDraggableList/ImageEditAnswerItem";
import ButtonsOptions from "../ButtonsLayout/ButtonsOptions";
import { UploadImageModal } from "../../UploadImage/UploadImageModal";
import SwitchOptionsAndPict from "./switchOptionsAndPict";
import { CropModalInit } from "@/ui_kit/Modal/CropModal";
import imge from "@/assets/card-1.png"
interface Props {
question: QuizQuestionVarImg;
@ -18,33 +24,48 @@ interface Props {
setOpenBranchingPage: (a: boolean) => void;
}
export default function OptionsAndPicture({ question, setOpenBranchingPage }: Props) {
export default function OptionsAndPicture({
question,
setOpenBranchingPage,
}: Props) {
const [switchState, setSwitchState] = useState("setting");
const [pictureUploding, setPictureUploading] = useState<boolean>(false);
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
const [openCropModal, setOpenCropModal] = useState(false);
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(
null,
);
const variant = question.content.variants.find(
(variant) => variant.id === selectedVariantId,
)
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const quizQid = useCurrentQuiz()?.qid;
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
const { isCropModalOpen, openCropModal, closeCropModal, imageBlob, originalImageUrl, setCropModalImageBlob } =
useCropModalState();
const handleImageUpload = async (file: File) => {
if (!selectedVariantId) return;
setPictureUploading(true);
const url = await uploadQuestionImage(question.id, quizQid, file, (question, url) => {
closeImageUploadModal();
const url = await uploadQuestionImage(
question.id,
quizQid,
file,
(question, url) => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find((variant) => variant.id === selectedVariantId);
const variant = question.content.variants.find(
(variant) => variant.id === selectedVariantId,
);
if (!variant) return;
variant.extendedText = url;
variant.originalImageUrl = url;
});
closeImageUploadModal();
openCropModal(file, url);
},
);
setOpenCropModal(true)
setPictureUploading(false);
};
@ -55,7 +76,9 @@ export default function OptionsAndPicture({ question, setOpenBranchingPage }: Pr
uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find((variant) => variant.id === selectedVariantId);
const variant = question.content.variants.find(
(variant) => variant.id === selectedVariantId,
);
if (!variant) return;
variant.extendedText = url;
@ -73,9 +96,7 @@ export default function OptionsAndPicture({ question, setOpenBranchingPage }: Pr
<Box sx={{ pl: "20px", pr: "20px" }}>
<AnswerDraggableList
questionId={question.id}
variants={question.content.variants
.filter(variant => !variant.isOwn ? true : question.content.own && variant.isOwn)
.map((variant, index) => (
variants={question.content.variants.map((variant, index) => (
<ImageEditAnswerItem
key={variant.id}
index={index}
@ -84,7 +105,7 @@ export default function OptionsAndPicture({ question, setOpenBranchingPage }: Pr
largeCheck={question.content.largeCheck}
variant={variant}
isMobile={isMobile}
openCropModal={openCropModal}
openCropModal={() => {setOpenCropModal(true)}}
openImageUploadModal={openImageUploadModal}
pictureUploding={pictureUploding}
setSelectedVariantId={setSelectedVariantId}
@ -98,17 +119,16 @@ export default function OptionsAndPicture({ question, setOpenBranchingPage }: Pr
onClose={closeImageUploadModal}
handleImageChange={handleImageUpload}
/>
<CropModal
isOpen={isCropModalOpen}
imageBlob={imageBlob}
originalImageUrl={originalImageUrl}
setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick}
onDeleteClick={() => {
if (selectedVariantId) clearQuestionImages(question.id, selectedVariantId);
}}
cropAspectRatio={{ width: 300, height: 300 }}
<CropModalInit
originalImageUrl={variant?.originalImageUrl}
editedUrlImagesList={variant?.editedUrlImagesList}
questionId={question.id.toString()}
questionType={question.type}
quizId={quizQid}
variantId={variant?.id}
open={openCropModal}
selfClose={() => setOpenCropModal(false)}
setPictureUploading={setPictureUploading}
/>
<Box
sx={{

@ -1,10 +1,14 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { clearQuestionImages, 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";
import { useMemo, useState } from "react";
import { EnterIcon } from "@/assets/icons/questionsPage/enterIcon";
import type { QuizQuestionImages } from "@frontend/squzanswerer";
import type { QuizQuestionVarImg } from "@frontend/squzanswerer/dist-package/model/questionTypes/varimg";
//@/model/questionTypes/images";
import { useAddAnswer } from "@/utils/hooks/useAddAnswer";
import { useDisclosure } from "@/utils/useDisclosure";
import { AnswerDraggableList } from "../../AnswerDraggableList";
@ -13,65 +17,68 @@ import ButtonsOptions from "../ButtonsLayout/ButtonsOptions";
import { UploadImageModal } from "../../UploadImage/UploadImageModal";
import SwitchAnswerOptionsPict from "./switchOptionsPict";
import imge from "@/assets/card-1.png"
import { CropModalInit } from "@/ui_kit/Modal/CropModal";
interface Props {
question: QuizQuestionImages;
question: QuizQuestionVarImg;
openBranchingPage: boolean;
setOpenBranchingPage: (a: boolean) => void;
}
export default function OptionsPicture({ question, openBranchingPage, setOpenBranchingPage }: Props) {
export default function OptionsPicture({
question,
openBranchingPage,
setOpenBranchingPage,
}: Props) {
const theme = useTheme();
const {onClickAddAnAnswer} = useAddAnswer();
const onClickAddAnAnswer = useAddAnswer();
const quizQid = useCurrentQuiz()?.qid;
const [pictureUploding, setPictureUploading] = useState<boolean>(false);
const [openCropModal, setOpenCropModal] = useState(false);
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
const variant = question.content.variants.find(
(variant) => variant.id === selectedVariantId,
)
const [switchState, setSwitchState] = useState("setting");
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
const { isCropModalOpen, openCropModal, closeCropModal, imageBlob, originalImageUrl, setCropModalImageBlob } = useCropModalState();
const handleImageUpload = async (file: File) => {
if (!selectedVariantId) return;
setPictureUploading(true);
const url = await uploadQuestionImage(question.id, quizQid, file, (question, url) => {
closeImageUploadModal();
const url = await uploadQuestionImage(
question.id,
quizQid,
file,
(question, url) => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find((variant) => variant.id === selectedVariantId);
const variant = question.content.variants.find(
(variant) => variant.id === selectedVariantId,
);
if (!variant) return;
variant.extendedText = url;
variant.originalImageUrl = url;
});
},
);
closeImageUploadModal();
openCropModal(file, url);
setOpenCropModal(true)
setPictureUploading(false);
};
function handleCropModalSaveClick(imageBlob: Blob) {
if (!selectedVariantId) return;
uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find((variant) => variant.id === selectedVariantId);
if (!variant) return;
variant.extendedText = url;
});
}
return (
<>
<Box sx={{ padding: "20px" }}>
<AnswerDraggableList
questionId={question.id}
variants={question.content.variants
.filter(variant => !variant.isOwn ? true : question.content.own && variant.isOwn)
.map((variant, index) => (
variants={question.content.variants.map((variant, index) => (
<ImageEditAnswerItem
key={variant.id}
index={index}
@ -80,7 +87,7 @@ export default function OptionsPicture({ question, openBranchingPage, setOpenBra
largeCheck={question.content.largeCheck}
variant={variant}
isMobile={isMobile}
openCropModal={openCropModal}
openCropModal={() => {setOpenCropModal(true)}}
openImageUploadModal={openImageUploadModal}
pictureUploding={pictureUploding}
setSelectedVariantId={setSelectedVariantId}
@ -94,17 +101,16 @@ export default function OptionsPicture({ question, openBranchingPage, setOpenBra
onClose={closeImageUploadModal}
handleImageChange={handleImageUpload}
/>
<CropModal
isOpen={isCropModalOpen}
imageBlob={imageBlob}
originalImageUrl={originalImageUrl}
setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick}
onDeleteClick={() => {
if (selectedVariantId) clearQuestionImages(question.id, selectedVariantId);
}}
cropAspectRatio={{ width: 452, height: 300 }}
<CropModalInit
originalImageUrl={variant?.originalImageUrl}
editedUrlImagesList={variant?.editedUrlImagesList}
questionId={question.id.toString()}
questionType={question.type}
quizId={quizQid}
variantId={variant?.id}
open={openCropModal}
selfClose={() => setOpenCropModal(false)}
setPictureUploading={setPictureUploading}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link

@ -4,6 +4,7 @@ import { updateQuestion, uploadQuestionImage } from "@root/questions/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { useState } from "react";
import { DropZone } from "../../../pages/startPage/dropZone";
import { CropModalInit } from "@/ui_kit/Modal/CropModal";
type UploadImageProps = {
question: AnyTypedQuizQuestion;
@ -15,6 +16,8 @@ type UploadImageProps = {
export default function UploadImage({ question, cropAspectRatio }: UploadImageProps) {
const [pictureUploding, setPictureUploading] = useState<boolean>(false);
const [openCropModal, setOpenCropModal] = useState(false);
const theme = useTheme();
const quiz = useCurrentQuiz();
@ -33,43 +36,53 @@ export default function UploadImage({ question, cropAspectRatio }: UploadImagePr
Загрузить изображение
</Typography>
{pictureUploding ? (
<Skeleton
variant="rounded"
sx={{ height: "120px", width: "300px" }}
/>
<Skeleton variant="rounded" sx={{ height: "120px", width: "300px" }} />
) : (
<DropZone
text={"5 MB максимум"}
sx={{ maxWidth: "300px", width: "100%" }}
cropAspectRatio={cropAspectRatio}
imageUrl={question.content.back}
imageUrl={question.content.originalBack}
originalImageUrl={question.content.originalBack}
onImageUploadClick={async (file) => {
setPictureUploading(true);
await uploadQuestionImage(question.id, quiz.qid, file, (question, url) => {
await uploadQuestionImage(
question.id,
quiz.qid,
file,
(question, url) => {
question.content.back = url;
question.content.originalBack = url;
});
},
);
setOpenCropModal(true)
setPictureUploading(false);
}}
}
}
onDeleteClick={() => {
updateQuestion(question.id, (question) => {
question.content.back = null;
question.content.originalBack = null;
if ("editedUrlImagesList" in question.content) question.content.editedUrlImagesList = null;
});
}}
onImageSaveClick={async (file) => {
setPictureUploading(true);
await uploadQuestionImage(question.id, quiz.qid, file, (question, url) => {
question.content.back = url;
});
setPictureUploading(false);
onImageSavedClick={() => {
setOpenCropModal(true)
}}
/>
)}
<CropModalInit
originalImageUrl={question.content.originalBack}
editedUrlImagesList={question.content?.editedUrlImagesList}
questionId={question.id.toString()}
questionType={question.type}
quizId={quiz.qid}
open={openCropModal}
selfClose={() => setOpenCropModal(false)}
setPictureUploading={setPictureUploading}
/>
</Box>
);
}

@ -16,8 +16,6 @@ export const ListOfImagesCardAnswer = ({
quizId,
questionId,
}: Props) => {
console.log('"' + answer + '"')
console.log(splitUserText('"' + answer + '"'))
return <Box
sx={{
@ -36,9 +34,6 @@ export const ListOfImagesCardAnswer = ({
.filter(text => text.length)
.map(text => {
const { Image, Description } = JSON.parse(text)
console.log("Image, Descripton")
console.log(Image, Description)
console.log("Image, Descripton")
return (<>
<img
width={40}

@ -1,5 +1,5 @@
import { useState } from "react";
import UploadIcon from "@icons/UploadIcon";
import DeleteIcon from "@mui/icons-material/Delete";
import {
Box,
ButtonBase,
@ -10,11 +10,13 @@ import {
useTheme,
} from "@mui/material";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { CropModal, useCropModalState } from "@ui_kit/Modal/CropModal";
import { enqueueSnackbar } from "notistack";
import { useState } from "react";
import { UploadImageModal } from "../../pages/Questions/UploadImage/UploadImageModal";
import { useDisclosure } from "../../utils/useDisclosure";
import imge from "@/assets/card-1.png"
import { CropModalInit } from "@/ui_kit/Modal/CropModal";
import DeleteIcon from "@mui/icons-material/Delete";
const allowedFileTypes = ["image/png", "image/jpeg", "image/gif"];
@ -25,7 +27,7 @@ interface Props {
imageUrl: string | null;
originalImageUrl: string | null;
onImageUploadClick: (image: Blob) => void;
onImageSaveClick: (image: Blob) => void;
onImageSavedClick?: () => void;
onDeleteClick: () => void;
cropAspectRatio?: {
width: number;
@ -41,22 +43,15 @@ export const DropZone = ({
imageUrl,
originalImageUrl,
onImageUploadClick,
onImageSaveClick,
onImageSavedClick,
onDeleteClick,
cropAspectRatio,
}: Props) => {
const theme = useTheme();
const quiz = useCurrentQuiz();
const [isDropReady, setIsDropReady] = useState<boolean>(false);
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] =
useDisclosure();
const {
isCropModalOpen,
openCropModal,
closeCropModal,
imageBlob,
setCropModalImageBlob,
} = useCropModalState();
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
if (!quiz) return null;
@ -68,7 +63,6 @@ export const DropZone = ({
onImageUploadClick?.(file);
closeImageUploadModal();
openCropModal(file);
}
const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
@ -102,18 +96,8 @@ export const DropZone = ({
onClose={closeImageUploadModal}
handleImageChange={handleImageUpload}
/>
<CropModal
isOpen={isCropModalOpen}
imageBlob={imageBlob}
originalImageUrl={originalImageUrl}
setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal}
onSaveImageClick={onImageSaveClick}
cropAspectRatio={cropAspectRatio}
/>
<ButtonBase
onClick={
imageUrl ? () => openCropModal(imageUrl) : openImageUploadModal
onClick={ () => onImageSavedClick &&imageUrl ? onImageSavedClick() : openImageUploadModal()
}
sx={{
width: "100%",
@ -152,6 +136,7 @@ export const DropZone = ({
</Typography>
</>
)}
</ButtonBase>
{imageUrl && (
<IconButton

@ -9,6 +9,7 @@ import { produce } from "immer";
import { nanoid } from "nanoid";
import { enqueueSnackbar } from "notistack";
import { defaultQuestionByType } from "../../constants/default";
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
import { replaceEmptyLinesToSpace } from "../../utils/replaceEmptyLinesToSpace";
import { RequestQueue } from "../../utils/requestQueue";
import { QuestionsStore, useQuestionsStore } from "./store";

@ -1,45 +1,61 @@
import InfoIcon from "@icons/InfoIcon";
import UploadIcon from "@icons/UploadIcon";
import { Box, Button, ButtonBase, Skeleton, Tooltip, Typography, useTheme } from "@mui/material";
import { updateQuestion, uploadQuestionImage } from "@root/questions/actions";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import { CropModal, useCropModalState } from "@ui_kit/Modal/CropModal";
import UploadBox from "@ui_kit/UploadBox";
import { FC, useState } from "react";
import { UploadImageModal } from "../pages/Questions/UploadImage/UploadImageModal";
import { VideoElement } from "../pages/startPage/VideoElement";
import { useCurrentQuiz } from "../stores/quizes/hooks";
import { useDisclosure } from "../utils/useDisclosure";
import { QuizQuestionPage, QuizQuestionResult } from "@frontend/squzanswerer";
import UploadVideoModal from "@/pages/Questions/UploadVideoModal";
import {
Box,
Button,
ButtonBase,
Skeleton,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import { updateQuestion, uploadQuestionImage } from "@root/questions/actions";
interface Props {
question: QuizQuestionPage | QuizQuestionResult;
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import { UploadImageModal } from "@/pages/Questions/UploadImage/UploadImageModal";
import { VideoElement } from "@/pages/startPage/VideoElement";
import { useCurrentQuiz } from "@/stores/quizes/hooks";
import { useDisclosure } from "@/utils/useDisclosure";
import UploadBox from "@ui_kit/UploadBox";
import UploadIcon from "@icons/UploadIcon";
import InfoIcon from "@icons/InfoIcon";
import imge from "@/assets/card-1.png"
import { CropModalInit } from "./Modal/CropModal";
import { AnyTypedQuizQuestion } from "@frontend/squzanswerer";
interface Iprops {
question: AnyTypedQuizQuestion;
cropAspectRatio: {
width: number;
height: number;
};
}
export const MediaSelectionAndDisplay: FC<Props> = ({ question, cropAspectRatio }) => {
export const MediaSelectionAndDisplay: FC<Iprops> = ({
question,
cropAspectRatio,
}) => {
const [pictureUploding, setPictureUploading] = useState<boolean>(false);
const [backgroundUploding, setBackgroundUploading] = useState<boolean>(false);
const [openCropModal, setOpenCropModal] = useState(false);
const quizQid = useCurrentQuiz()?.qid;
const theme = useTheme();
const { isCropModalOpen, openCropModal, closeCropModal, imageBlob, originalImageUrl, setCropModalImageBlob } =
useCropModalState();
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
const [isVideoUploadDialogOpen, setIsVideoUploadDialogOpen] = useState<boolean>(false);
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] =
useDisclosure();
async function handleImageUpload(file: File) {
setPictureUploading(true);
const url = await uploadQuestionImage(question.id, quizQid, file, (question, url) => {
closeImageUploadModal();
const url = await uploadQuestionImage(
question.id,
quizQid,
file,
(question, url) => {
question.content.back = url;
question.content.originalBack = url;
});
closeImageUploadModal();
openCropModal(file, url);
},
);
setOpenCropModal(true)
setPictureUploading(false);
}
@ -95,11 +111,10 @@ export const MediaSelectionAndDisplay: FC<Props> = ({ question, cropAspectRatio
}}
variant="text"
onClick={() =>
updateQuestion(question.id, (question) => {
if (!("useImage" in question.content)) return;
question.content.useImage = true;
})
updateQuestion(
question.id,
(question) => (question.content.useImage = true),
)
}
>
Изображение
@ -124,32 +139,30 @@ export const MediaSelectionAndDisplay: FC<Props> = ({ question, cropAspectRatio
Видео
</Button>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<UploadImageModal
isOpen={isImageUploadOpen}
onClose={closeImageUploadModal}
handleImageChange={handleImageUpload}
/>
<CropModal
isOpen={isCropModalOpen}
imageBlob={imageBlob}
originalImageUrl={originalImageUrl}
setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick}
onDeleteClick={() => {
updateQuestion(question.id, (question) => {
question.content.back = null;
question.content.originalBack = null;
});
}}
cropAspectRatio={cropAspectRatio}
/>
<UploadVideoModal
open={isVideoUploadDialogOpen}
onClose={() => setIsVideoUploadDialogOpen(false)}
onUpload={handleVideoUpload}
video={question.content.video}
<CropModalInit
originalImageUrl={question.content.originalBack}
editedUrlImagesList={question.content?.editedUrlImagesList}
questionId={question.id}
questionType={question.type}
quizId={quizQid}
open={openCropModal}
selfClose={() => setOpenCropModal(false)}
setPictureUploading={setPictureUploading}
/>
</Box>
{question.content.useImage && (
<Box
sx={{
@ -161,14 +174,14 @@ export const MediaSelectionAndDisplay: FC<Props> = ({ question, cropAspectRatio
}}
>
<AddOrEditImageButton
imageSrc={question.content.back ?? undefined}
imageSrc={question.content.back}
uploading={pictureUploding}
onImageClick={() => {
if (question.content.back) {
return openCropModal(question.content.back, question.content.originalBack);
}
setOpenCropModal(true)
} else {
openImageUploadModal();
}
}}
onPlusClick={() => {
openImageUploadModal();
@ -221,6 +234,27 @@ export const MediaSelectionAndDisplay: FC<Props> = ({ question, cropAspectRatio
my: "20px",
}}
>
<input
onChange={async (event) => {
setBackgroundUploading(true);
const file = event.target.files?.[0];
if (file) {
await uploadQuestionImage(
question.id,
quizQid,
file,
(question, url) => {
question.content.video = url;
},
);
}
setBackgroundUploading(false);
}}
hidden
accept=".mp4"
multiple
type="file"
/>
<UploadBox
icon={<UploadIcon />}
sx={{
@ -246,7 +280,8 @@ export const MediaSelectionAndDisplay: FC<Props> = ({ question, cropAspectRatio
/>
)}
</>
)}
</Box>
)
}
</Box >
);
};

@ -1,438 +0,0 @@
import { devlog } from "@frontend/kitui";
import { ResetIcon } from "@icons/ResetIcon";
import DeleteIcon from "@mui/icons-material/Delete";
import {
Box,
Button,
IconButton,
Modal,
Slider,
SxProps,
Theme,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { enqueueSnackbar } from "notistack";
import { FC, useCallback, useMemo, useRef, useState } from "react";
import ReactCrop, {
PercentCrop,
centerCrop,
convertToPixelCrop,
makeAspectCrop,
} from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { isImageBlobAGifFile } from "../../utils/isImageBlobAGifFile";
import {
getModifiedImageBlob,
getRotatedImageBlob,
} from "./utils/imageManipulation";
const styleSlider: SxProps<Theme> = {
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`,
},
},
};
interface Props {
isOpen: boolean;
imageBlob: Blob | null;
originalImageUrl: string | null;
setCropModalImageBlob: (imageBlob: Blob) => void;
onClose: () => void;
onSaveImageClick: (imageBlob: Blob) => void;
onDeleteClick?: () => void;
cropAspectRatio?: {
width: number;
height: number;
};
}
export const CropModal: FC<Props> = ({
isOpen,
imageBlob,
originalImageUrl,
setCropModalImageBlob,
onSaveImageClick,
onDeleteClick,
onClose,
cropAspectRatio,
}) => {
const theme = useTheme();
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);
const cropImageElementRef = useRef<HTMLImageElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down(786));
const imageUrl = useMemo(
() => imageBlob && URL.createObjectURL(imageBlob),
[imageBlob],
);
function resetEditState() {
setPercentCrop(undefined);
setDarken(0);
}
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 getModifiedImageBlob(
cropImageElementRef.current,
pixelCrop,
darken,
);
onSaveImageClick?.(blob);
resetEditState();
onClose();
} catch (error) {
devlog("getCroppedImageBlob error", error);
enqueueSnackbar("Не удалось изменить изображение");
}
}
async function handleRotateClick() {
if (!cropImageElementRef.current) throw new Error("No image");
try {
const blob = await getRotatedImageBlob(cropImageElementRef.current);
setCropModalImageBlob(blob);
setPercentCrop(undefined);
} catch (error) {
devlog("getRotatedImageBlob error", error);
enqueueSnackbar("Не удалось изменить изображение");
}
}
async function handleSaveOriginalImage() {
if (!originalImageUrl) return;
const response = await fetch(originalImageUrl);
const blob = await response.blob();
onSaveImageClick?.(blob);
resetEditState();
onClose();
}
function handleSizeChange(value: number) {
setPercentCrop((prev) => {
if (!imageWidth || !imageHeight) return;
const crop = makeAspectCrop(
{
unit: "%",
width: value,
x: 0,
y: 0,
},
cropAspectRatio ? cropAspectRatio.width / cropAspectRatio.height : 1,
imageWidth,
imageHeight,
);
if (!prev || prev.height === 0 || prev.width === 0) {
return centerCrop(crop, imageWidth, imageHeight);
}
crop.x = Math.min(
100 - crop.width,
Math.max(0, prev.x + (prev.width - crop.width) / 2),
);
crop.y = Math.min(
100 - crop.height,
Math.max(0, prev.y + (prev.height - crop.height) / 2),
);
return crop;
});
}
return (
<Modal
open={isOpen}
onClose={() => {
resetEditState();
onClose();
}}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
boxShadow: 24,
padding: "20px",
borderRadius: "8px",
width: isMobile ? "343px" : "620px",
height: isMobile ? "80vh" : undefined,
display: isMobile ? "flex" : undefined,
flexDirection: isMobile ? "column" : undefined,
justifyContent: isMobile ? "flex-start" : undefined,
overflow: isMobile ? "auto" : undefined,
}}
>
<Box
sx={{
height: "320px",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{imageUrl && (
<ReactCrop
crop={percentCrop}
onChange={(_, percentCrop) => setPercentCrop(percentCrop)}
minWidth={5}
minHeight={5}
locked
aspect={
cropAspectRatio
? cropAspectRatio.width / cropAspectRatio.height
: undefined
}
>
<img
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"
src={imageUrl}
style={{
filter: `brightness(${100 - darken}%)`,
maxWidth: "100%",
maxHeight: "320px",
display: "block",
objectFit: "contain",
}}
/>
</ReactCrop>
)}
</Box>
<Box
sx={{
mt: "40px",
display: isMobile ? "block" : "flex",
alignItems: "end",
justifyContent: "space-between",
}}
>
<IconButton onClick={handleRotateClick}>
<ResetIcon />
</IconButton>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Размер
</Typography>
<Slider
sx={[
styleSlider,
{
width: isMobile ? undefined : "200px",
},
]}
value={percentCrop?.width ?? 1}
min={1}
max={100}
step={0.1}
onChange={(_, newValue) => {
if (typeof newValue === "number") handleSizeChange(newValue);
}}
/>
</Box>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Затемнение
</Typography>
<Slider
sx={[
styleSlider,
{
width: isMobile ? undefined : "200px",
},
]}
value={darken}
min={0}
max={100}
step={1}
onChange={(_, newValue) => setDarken(newValue as number)}
/>
</Box>
{onDeleteClick && (
<IconButton
onClick={() => {
onDeleteClick?.();
onClose();
}}
sx={{
height: "48px",
width: "48px",
p: 0,
color: theme.palette.orange.main,
borderRadius: "50%",
}}
>
<DeleteIcon />
</IconButton>
)}
</Box>
<Box
sx={{
marginTop: "40px",
width: "100%",
display: "flex",
gap: "10px",
flexWrap: isMobile ? "wrap" : undefined,
}}
>
<Button
onClick={handleSaveOriginalImage}
disableRipple
sx={{
height: "48px",
color: "#7E2AEA",
borderRadius: "8px",
border: "1px solid #7E2AEA",
px: "20px",
}}
>
Сохранить оригинал
</Button>
<Button
onClick={handleSaveModifiedImage}
disableRipple
variant="contained"
sx={{
height: "48px",
borderRadius: "8px",
border: "1px solid #7E2AEA",
marginRight: "10px",
px: "20px",
ml: "auto",
}}
>
Сохранить редактированное
</Button>
</Box>
</Box>
</Modal>
);
};
export function useCropModalState(initialOpenState = false) {
const [isCropModalOpen, setOpened] = useState(initialOpenState);
const [imageBlob, setCropModalImageBlob] = useState<Blob | null>(null);
const [originalImageUrl, setOriginalImageUrl] = useState<string | null>(null);
const closeCropModal = useCallback(() => {
setOpened(false);
setCropModalImageBlob(null);
setOriginalImageUrl(null);
}, []);
const openCropModal = useCallback(
async (
image: Blob | string,
originalImageUrl: string | null | undefined = null,
) => {
if (typeof image === "string") {
const response = await fetch(image);
image = await response.blob();
}
const isGif = await isImageBlobAGifFile(image);
if (isGif) return;
setCropModalImageBlob(image);
setOriginalImageUrl(originalImageUrl);
setOpened(true);
},
[],
);
return {
isCropModalOpen,
openCropModal,
closeCropModal,
imageBlob,
setCropModalImageBlob,
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,
);
}

@ -0,0 +1,53 @@
import { Box, Button, Modal, Typography, useMediaQuery, useTheme } from "@mui/material";
interface Props {
open: boolean;
cancelDelete: () => void;
deleteImage: () => void;
}
export default ({
open,
cancelDelete,
deleteImage,
}: Props) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(450));
return (
<Modal
open={open}
onClose={cancelDelete}
>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
boxShadow: 24,
borderRadius: "12px",
width: isMobile ? "200px" : "350px",
height: "350px",
display: "flex",
flexDirection: "column",
}}
>
<Typography sx={{
// height: isMobile ? "91px" : "70px",
backgroundColor: "#F2F3F7",
padding: isMobile ? "25px 20px 24px 20px" : "25px 43px 24px 20px",
borderRadius: "8px 8px 0px 0px",
color: "#9A9AAF",
fontSize: "18px",
lineHeight: "21.33px"
}}>
Вы уверены, что хотите удалить всю картинку и каждую настройку?
</Typography>
<Button sx={{margin: "25px"}} variant="contained" onClick={cancelDelete}>нет</Button>
<Button sx={{margin: "25px"}} variant="outlined" onClick={deleteImage}>да</Button>
</Box>
</Modal>
);
};

@ -0,0 +1,328 @@
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { enqueueSnackbar } from "notistack";
import ReactCrop, {
PercentCrop,
centerCrop,
convertToPixelCrop,
makeAspectCrop,
type Crop
} from "react-image-crop";
import {
getModifiedImageBlob,
getRotatedImageBlob,
} from "./utils/imageManipulation";
import {
Box,
IconButton,
Slider,
SxProps,
Theme,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { ResetIcon } from "@icons/ResetIcon";
import "react-image-crop/dist/ReactCrop.css";
import { EditedImagesChangeType } from "./CropModal";
import { CropAspectRatio, DEFAULTCROPRULES, EditedImage } from "@/model/CropModal/CropModal";
import { devlog } from "@frontend/kitui";
const styleSlider: SxProps<Theme> = {
color: "#7E2AEA",
height: "10px",
p: "18px 0",
"& .MuiSlider-track": {
border: "none",
},
"& .MuiSlider-rail": {
backgroundColor: "#F2F3F7",
border: `1px solid #9A9AAF`,
},
"& .MuiSlider-thumb": {
height: 24,
width: 24,
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`,
},
},
};
interface Props {
editedImage: EditedImage;
cropAspectRatio: CropAspectRatio;
editedImagesChange: EditedImagesChangeType;
};
export const CropGeneral: FC<Props> = ({
editedImage,
cropAspectRatio,
editedImagesChange,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(786));
const { crop, darken } = editedImage.newRules;
const cropImageElementRef = useRef<HTMLImageElement>(null);
const [imageWidth, setImageWidth] = useState<number | null>(null);
const [imageHeight, setImageHeight] = useState<number | null>(null);
async function handleRotateClick() {
if (cropImageElementRef.current !== null) {
try {
const blob = await getRotatedImageBlob(cropImageElementRef.current);
editedImagesChange((old) => {
const newRotate = old.newRules.rotate + 90;
return {
newRules: {
...old.newRules,
rotate: newRotate > 360 ? 0 : newRotate
},
url: (URL.createObjectURL(blob))
};
});
} catch (error) {
devlog("getRotatedImageBlob error", error);
enqueueSnackbar("Не удалось изменить изображение");
}
}
}
function handleSizeChange(value: number) {
editedImagesChange((old) => {
if (!imageWidth || !imageHeight) return old;
const crop = makeAspectCrop(
{
unit: "%",
width: value,
x: 0,
y: 0,
},
cropAspectRatio ? cropAspectRatio.width / cropAspectRatio.height : 1,
imageWidth,
imageHeight,
);
//Хз зачем это было нужно, как будет работать - перетещу
// if (!old.newRules.crop || old.newRules.crop.height === 0 || old.newRules.crop.width === 0) {
// return centerCrop(crop, imageWidth, imageHeight);
// }
crop.x = Math.min(
100 - crop.width,
Math.max(0, old.newRules.crop.x + (old.newRules.crop.width - crop.width) / 2),
);
crop.y = Math.min(
100 - crop.height,
Math.max(0, old.newRules.crop.y + (old.newRules.crop.height - crop.height) / 2),
);
return {
newRules: {
...old.newRules,
crop
}
};
});
}
const calcCrop = () => {
if (cropImageElementRef.current) {
editedImagesChange((old) => ({
newRules: {
...old.newRules,
crop: getInitialCrop(
cropImageElementRef.current?.width,
cropImageElementRef.current?.height,
cropAspectRatio
? cropAspectRatio.width / cropAspectRatio.height
: 2,
)
}
}))
}
}
useEffect(() => {
if (!crop.width && !crop.height) { calcCrop() }
}, [crop])
return (
<>
<Box
sx={{
height: "320px",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 20px",
marginTop: isMobile ? "19px" : "20px",
}}
>
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => editedImagesChange((old) => {
return ({
newRules: {
...old.newRules,
crop: percentCrop
}
})
}
)}
minWidth={5}
minHeight={5}
locked
aspect={
cropAspectRatio
? cropAspectRatio.width / cropAspectRatio.height
: undefined
}
>
<img
id="imgid"
onLoad={(e) => {
setImageWidth(e.currentTarget.naturalWidth);
setImageHeight(e.currentTarget.naturalHeight);
calcCrop()
}}
ref={cropImageElementRef}
alt="Crop me"
src={editedImage.url}
style={{
filter: `brightness(${100 - editedImage.newRules.darken}%)`,
maxWidth: "100%",
height: "320px",
maxHeight: "320px",
display: "block",
objectFit: "contain",
}}
crossOrigin = 'anonymous'
/>
</ReactCrop>
</Box>
<Box
sx={{
mt: isMobile ? "20px" : "48px",
display: "flex",
alignItems: "end",
justifyContent: "space-between",
padding: "0 20px",
flexDirection: isMobile ? "column" : "",
}}
>
<IconButton onClick={handleRotateClick}
sx={{
mb: "11px",
p: "0",
}}
>
<ResetIcon />
</IconButton>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexDirection: isMobile ? "column" : "",
width: isMobile ? "100%" : "auto",
gap: "24px",
}}
>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Размер
</Typography>
<Slider
sx={[
styleSlider,
{
width: isMobile ? undefined : "248px",
},
]}
value={crop.width}
min={1}
max={100}
step={0.1}
onChange={(_, newValue) => {
if (typeof newValue === "number") handleSizeChange(newValue);
}}
/>
</Box>
<Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px", ml: "-1px", }}>
Затемнение
</Typography>
<Slider
sx={[
styleSlider,
{
width: isMobile ? undefined : "248px",
},
]}
value={darken}
min={0}
max={100}
step={1}
onChange={(_, newValue) => editedImagesChange((old) => ({
newRules: {
...old.newRules,
darken: newValue as number
}
}))}
/>
</Box>
</Box>
</Box>
</>
)
};
function getInitialCrop(
imageWidth: number | null | undefined,
imageHeight: number | null | undefined,
aspectRatio: number,
): PercentCrop {
if (!imageHeight || !imageWidth) return DEFAULTCROPRULES.crop
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,
);
}

@ -0,0 +1,116 @@
import { FC, useEffect, useMemo, useRef, useState } from "react";
import WorkSpace from "./WorkSpace";
import { NavigationPanel } from "./NavigationPanel";
import {
Box,
Modal,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import {
DEFAULTCROPRULES,
type CropModalProps,
type EditedImage,
type ScreenStepsTypes
} from "@/model/CropModal/CropModal";
const PriorityOfSteps = ["desktop", "tablet", "mobile", "small"];
export type EditedImagesChangeType = (changed: (old: EditedImage) => Partial<EditedImage>) => void;
export const CropModal: FC<CropModalProps> = ({
open,
editedImages,
workSpaceTypes,
originalImageUrl,
setEditedImages,
onSaveImageClick,
closeCropModal,
onDeleteClick,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(786));
const [currentStep, setCurrentStep] = useState<number>(0);
const currentStepName: ScreenStepsTypes = useMemo(() => {
const mainSteps = Object.keys(workSpaceTypes);
const POS = PriorityOfSteps.filter(POS => mainSteps.find(e => POS === e))
return POS[currentStep] as ScreenStepsTypes
}, [currentStep])
const editedImagesChange: EditedImagesChangeType = (changed) => {
setEditedImages(old => {
const newData = { ...old };
newData[currentStepName] = { ...old[currentStepName], ...changed(old[currentStepName]) };
return newData;
})
}
const resetImage = () => {
editedImagesChange(() => ({
url: originalImageUrl,
newRules: DEFAULTCROPRULES,
}))
}
return (
<Modal
open={open}
onClose={closeCropModal}
>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
boxShadow: 24,
borderRadius: "12px",
width: isMobile ? "343px" : "620px",
height: isMobile ? "80vh" : undefined,
display: isMobile ? "flex" : undefined,
flexDirection: isMobile ? "column" : undefined,
justifyContent: isMobile ? "flex-start" : undefined,
overflow: isMobile ? "auto" : undefined,
}}
>
<Typography sx={{
// height: isMobile ? "91px" : "70px",
backgroundColor: "#F2F3F7",
padding: isMobile ? "25px 20px 24px 20px" : "25px 43px 24px 20px",
borderRadius: "8px 8px 0px 0px",
color: "#9A9AAF",
fontSize: "18px",
lineHeight: "21.33px"
}}>
Настройте вариант отображения картинки на разных девайсах
</Typography>
<WorkSpace
//Информация о изменяемой сейчас картинке
editedImage={editedImages[currentStepName]}
//По каким правилам меняем
cropAspectRatio={workSpaceTypes[currentStepName].ratio}
currentStep={currentStep}
currentStepName={currentStepName}
editedImagesChange={editedImagesChange}
onDeleteClick={onDeleteClick}
/>
<NavigationPanel
currentStep={currentStep}
setCurrentStep={setCurrentStep}
totalSteps={Object.keys(workSpaceTypes).length}
onSaveImageClick={onSaveImageClick}
resetImage={resetImage}
/>
</Box>
</Modal>
);
};

@ -0,0 +1,24 @@
import {Box} from "@mui/material";
export default function DevaceDesktopIcon() {
return (
<Box
sx={{
height: "36px",
width: "36px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "6px",
backgroundColor: "#EEE4FC"
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="17" width="20" height="3" rx="1" stroke="#7E2AEA" strokeWidth="1.5"/>
<rect x="3" y="5" width="18" height="12" rx="1" stroke="#7E2AEA" strokeWidth="1.5"/>
<path d="M14 5.5L10 5.5" stroke="#7E2AEA" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</Box>
);
}

@ -0,0 +1,23 @@
import {Box} from "@mui/material";
export default function DevaceMobileIcon() {
return (
<Box
sx={{
height: "36px",
width: "36px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "6px",
backgroundColor: "#EEE4FC"
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="2" width="12" height="20" rx="3" stroke="#7E2AEA" strokeWidth="1.5"/>
<path d="M14 2.5L10 2.5" stroke="#7E2AEA" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</Box>
);
}

@ -0,0 +1,23 @@
import {Box} from "@mui/material";
export default function DevaceSmallIcon() {
return (
<Box
sx={{
height: "36px",
width: "36px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "6px",
backgroundColor: "#EEE4FC"
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7.5" y="2" width="9" height="20" rx="3" stroke="#7E2AEA" strokeWidth="1.5"/>
<path d="M13.5 2.5L10.5 2.5" stroke="#7E2AEA" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</Box>
);
}

@ -0,0 +1,24 @@
import {Box} from "@mui/material";
export default function DevaceTabletIcon() {
return (
<Box
sx={{
height: "36px",
width: "36px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "6px",
backgroundColor: "#EEE4FC"
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.5 19.5V4.5C19.5 3.67157 18.8284 3 18 3L6 3C5.17157 3 4.5 3.67157 4.5 4.5L4.5 19.5C4.5 20.3284 5.17157 21 6 21H18C18.8284 21 19.5 20.3284 19.5 19.5Z" stroke="#7E2AEA" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 4L10 4" stroke="#7E2AEA" strokeWidth="2.5" strokeLinecap="round"/>
<path d="M4.5 18H19.5" stroke="#7E2AEA" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</Box>
);
}

@ -0,0 +1,112 @@
import { FC } from "react"
import {
Box,
Button,
useMediaQuery,
useTheme,
} from "@mui/material";
import BackArrowIcon from "@icons/BackArrowIcon";
interface Props {
currentStep: number;
setCurrentStep: (setp: number) => void;
totalSteps: number;
onSaveImageClick: () => void;
resetImage: () => void;
}
export const NavigationPanel: FC<Props> = ({
currentStep,
setCurrentStep,
totalSteps,
onSaveImageClick,
resetImage,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(786));
const lastStep = currentStep + 1 === totalSteps;
const handlePrevStep = () => {
if (currentStep === 0) return;
setCurrentStep(currentStep - 1);
};
const handleNextStep = () => {
if (lastStep) {
onSaveImageClick();
} else {
setCurrentStep(currentStep + 1);
}
};
return (
<Box
sx={{
marginTop: "18px",
padding: "0 20px 20px",
width: "100%",
display: "flex",
gap: "5px",
flexWrap: isMobile ? "wrap" : undefined,
justifyContent: "space-between"
}}
>
<Button
onClick={resetImage}
disableRipple
sx={{
height: "48px",
color: "#7E2AEA",
borderRadius: "8px",
border: "1px solid #7E2AEA",
px: "20px",
width: isMobile ? "100%" : undefined,
}}
>
Сохранить оригинал
</Button>
<Box
sx={{
display: "flex",
gap: "10px",
}}
>
<Button
onClick={handlePrevStep}
variant="contained"
sx={{
fontSize: "16px",
padding: "10px 15px",
color: "#FFFFFF",
border: "1px solid #9A9AAF",
background: "#FFFFFF",
"&:hover": {
color: "#FFFFFF",
border: `1px solid ${theme.palette.primary.dark}`,
},
}}
>
<BackArrowIcon color={"#7E2AEA"} />
</Button>
<Button
onClick={handleNextStep}
disableRipple
variant="contained"
sx={{
height: "48px",
borderRadius: "8px",
border: "1px solid #7E2AEA",
p: "10px 19px",
width: isMobile ? "100%" : undefined,
}}
>
{lastStep ?
"Сохранить редактированное" : "Далее"
}
</Button>
</Box>
</Box>
)
}

@ -0,0 +1,127 @@
import { useMemo } from "react";
import {
Box,
IconButton,
Typography,
useTheme,
} from "@mui/material";
import { CropGeneral } from "./CropGeneral";
import DevaceDesktopIcon from "@ui_kit/Modal/CropModal/IconCropModal/DevaceDesktopIcon";
import DevaceTabletIcon from "@ui_kit/Modal/CropModal/IconCropModal/DevaceTabletIcon";
import DevaceMobileIcon from "@ui_kit/Modal/CropModal/IconCropModal/DevaceMobileIcon";
import DevaceSmallIcon from "@ui_kit/Modal/CropModal/IconCropModal/DevaceSmallIcon";
import type { CropAspectRatio, EditedImage, ScreenStepsTypes } from "@/model/CropModal/CropModal"
import { EditedImagesChangeType } from "./CropModal";
import AmoTrash from "@/assets/icons/AmoTrash";
const modalModels = {
"desktop": { name: "Десктоп", icon: <DevaceDesktopIcon /> },
"tablet": { name: "Планшет", icon: <DevaceTabletIcon /> },
"mobile": { name: "Телефон", icon: <DevaceMobileIcon /> },
"small": { name: "Самые узкие экраны", icon: <DevaceSmallIcon /> }
};
interface Props {
editedImage: EditedImage;
cropAspectRatio: CropAspectRatio;
currentStep: number;
currentStepName: ScreenStepsTypes;
editedImagesChange: EditedImagesChangeType;
onDeleteClick: () => void;
};
export default function WorkSpace({
editedImage,
currentStep,
currentStepName,
cropAspectRatio,
editedImagesChange,
onDeleteClick,
}: Props) {
const theme = useTheme();
const currentModel = useMemo(() => (
modalModels[currentStepName]
), [currentStepName]);
// console.log(" промежуточный рендер которому должно быть похуй")
return (
<>
<Box
sx={{
mt: "12px",
padding: "0 20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Box
sx={{
width: "100%"
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
width: "100%",
alignItems: "center"
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "13px"
}}
>
<Typography
sx={{
color: "#4D4D4D",
fontSize: "24px",
fontWeight: 500,
lineHeight: "28.44px",
}}
>{currentModel.name}</Typography>
{currentModel.icon}
</Box>
<IconButton
onClick={onDeleteClick}
sx={{
height: "24px",
width: "24px",
p: 0,
color: theme.palette.orange.main,
borderRadius: "50%",
mb:"5px",
}}
>
<AmoTrash/>
</IconButton>
</Box>
<Typography sx={{
fontSize: "13.8px",
color: "#9A9AAF",
lineHeight: "16px",
}}>
{currentStep + 1 + " шаг"}
</Typography>
</Box>
</Box>
<CropGeneral
editedImage={editedImage}
cropAspectRatio={cropAspectRatio}
editedImagesChange={editedImagesChange}
/>
</>
);
}

@ -0,0 +1,247 @@
import { FC, useEffect, useState } from "react";
import { CropModal } from "./CropModal";
import {
type ScreenStepsTypes,
type CropOnOpenType,
type EditedImages,
type CropOnDeleteIamgeClick,
type Writable,
workSpaceTypesList,
DEFAULTCROPRULES,
EditedImage,
WorkSpaceTypes,
} from "@/model/CropModal/CropModal"
import { isImageBlobAGifFile } from "@/utils/isImageBlobAGifFile";
import AlertModalDeleteImage from "./AlertModalDeleteImage"
import { getModifiedImageBlob } from "./utils/imageManipulation";
import { updateQuestion, uploadQuestionImage } from "@/stores/questions/actions";
export const CropModalInit: FC<CropOnOpenType> = ({
open,
originalImageUrl,
imageBlob,
editedUrlImagesList,
questionId,
questionType,
quizId,
variantId,
selfClose,
setPictureUploading,
}) => {
const [acceptedOriginalImageUrl, setOriginalImageUrl] = useState("");
const [editedImages, setEditedImages] = useState<Record<keyof WorkSpaceTypes, EditedImage> | null>(null);
const [readyDelete, setReadyDelete] = useState(false);
useEffect(() => {
if (open) {
//Если нам не дали с чем работать, то и работать не нужно
if (Boolean(imageBlob) || Boolean(originalImageUrl)) {
(async () => {
let newImageBlob = imageBlob;
if (originalImageUrl !== undefined) {
const response = await fetch(originalImageUrl);
newImageBlob = await response.blob();
};
if (newImageBlob) {
const isGif = await isImageBlobAGifFile(newImageBlob);
if (isGif) {
saveImagesAndRules(newImageBlob);
return setPictureUploading(false);
}
//Для работы нам нужны урлы. Оригинальной и редактированных картинок
let newOriginalImageUrl = originalImageUrl || URL.createObjectURL(newImageBlob)
if (questionId) {
if (questionType) {
const workSpaceTypesCONST = workSpaceTypesList[questionType];
type WritableWorkSpaceTypesCONST = Writable<typeof workSpaceTypesCONST>;
const writableEditedImagesList: WritableWorkSpaceTypesCONST = { ...workSpaceTypesList[questionType] }; // Теперь object не readonly
type Keys = keyof typeof writableEditedImagesList;
const newEditedImagesList = {} as Record<Keys, EditedImage>;
//Если вопрос умничка и знает что ему нужно
if (Boolean(editedUrlImagesList)) {
for (let k in editedUrlImagesList) {
let key = k as Keys;
if (key in writableEditedImagesList) {
newEditedImagesList[key] = {
...writableEditedImagesList[key],
url: editedUrlImagesList[key] ?? originalImageUrl,
newRules: DEFAULTCROPRULES
} as EditedImage;
}
}
} else { // Если в первый раз, то создаём ему бланк
for (let k in workSpaceTypesCONST) {
let key = k as Keys;
if (key in writableEditedImagesList) {
newEditedImagesList[key] = {
...writableEditedImagesList[key],
url: originalImageUrl,
newRules: DEFAULTCROPRULES
} as EditedImage;
}
}
}
setOriginalImageUrl(newOriginalImageUrl);
setEditedImages(newEditedImagesList);
} else {
throw new Error("Не передан тип вопроса")
}
} else {
throw new Error("Не передан id вопроса")
}
}
})()
}
}
}, [open, originalImageUrl, editedUrlImagesList, questionId, questionType])
const closeModal = () => {
setReadyDelete(false)
setOriginalImageUrl("");
setEditedImages(null);
selfClose()
}
const handleCropModalDeleteImageClick = () => {
if (variantId) {//Работаем с вариантом
updateQuestion(questionId, (question) => {
if ("variants" in question.content) {
const variant = question.content.variants.find(
(variant) => variant.id === variantId,
);
if (!variant) return console.error("В вопросе не был найден вариант ", variantId);
variant.extendedText = "";
variant.originalImageUrl = "";
if ("editedUrlImagesList" in variant) variant.editedUrlImagesList = null;
}
})
} else {//Работаем с контентом
updateQuestion(questionId, (question) => {
question.content.back = null;
question.content.originalBack = null;
if ("editedUrlImagesList" in question.content) question.content.editedUrlImagesList = null;
})
}
//сохранить пустую строку и дефолтные настройки картинки в самом вопросе, не информируя БД о удалении картинки
closeModal()
};
const saveImagesAndRules = async () => {
setPictureUploading(true)
if (editedImages === null) return;
closeModal();
//массив запросов
const requests: Promise<void>[] = [];
//Преобразовываем и отправляем на бек картинки.
if (variantId) {//Работаем с вариантом
for (let k in editedImages) {
requests.push(new Promise(async (resolve) => {
let key = k;
const img = document.createElement("img");
img.crossOrigin = 'anonymous';
img.src = editedImages[key].url;
const blob = await getModifiedImageBlob(img, editedImages[key].newRules.crop, editedImages[key].newRules.darken);
uploadQuestionImage(questionId, quizId, blob, (question, url) => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find(
(variant) => variant.id === variantId,
);
if (!variant) return;
if (!variant.editedUrlImagesList) variant.editedUrlImagesList = {}
variant.editedUrlImagesList[key] = url;
resolve()
});
}))
}
} else {//Работаем с контентом
for (let k in editedImages) {
requests.push(new Promise(async (resolve) => {
let key = k;
const img = document.createElement("img");
img.crossOrigin = 'anonymous';
img.src = editedImages[key].url;
const blob = await getModifiedImageBlob(img, editedImages[key].newRules.crop, editedImages[key].newRules.darken);
uploadQuestionImage(questionId, quizId, blob, (question, url) => {
if (!question.content.editedUrlImagesList) question.content.editedUrlImagesList = {};
question.content.editedUrlImagesList[key] = url;
resolve()
});
}))
}
}
await Promise.all(requests)
setPictureUploading(false);
};
if (acceptedOriginalImageUrl.length === 0) return <></>
if (workSpaceTypesList[questionType] === undefined) return <></>
if (editedImages === null) return <></>
return (
<>
<CropModal
open={open}
editedImages={editedImages}
workSpaceTypes={workSpaceTypesList[questionType]}
originalImageUrl={acceptedOriginalImageUrl}
setEditedImages={setEditedImages}
onSaveImageClick={saveImagesAndRules}
closeCropModal={closeModal}
onDeleteClick={() => setReadyDelete(true)}
/>
<AlertModalDeleteImage
open={readyDelete}
cancelDelete={() => setReadyDelete(false)}
deleteImage={handleCropModalDeleteImageClick}
/>
</>
)
};
// function handleCropModalSaveClick(imageBlob: Blob) { OptionsPicture
// if (!selectedVariantId) return;
// uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => {
// if (!("variants" in question.content)) return;
// const variant = question.content.variants.find(
// (variant) => variant.id === selectedVariantId,
// );
// if (!variant) return;
// variant.extendedText = url;
// });
// }

@ -1,4 +1,4 @@
import { PixelCrop } from "react-image-crop";
import { PercentCrop } from "react-image-crop";
export function getRotatedImageBlob(image: HTMLImageElement) {
return new Promise<Blob>((resolve, reject) => {
@ -23,7 +23,7 @@ export function getRotatedImageBlob(image: HTMLImageElement) {
export function getModifiedImageBlob(
image: HTMLImageElement,
crop: PixelCrop,
crop: PercentCrop,
darken: number,
) {
return new Promise<Blob>((resolve, reject) => {
@ -31,37 +31,25 @@ export function getModifiedImageBlob(
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;
// Пропорции исходного изображения
const originalWidth = image.width;
const originalHeight = image.height;
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
// Вычисляем размеры для обрезки в пикселях
const cropWidth = (crop.width / 100) * originalWidth;
const cropHeight = (crop.height / 100) * originalHeight;
const cropX = (crop.x / 100) * originalWidth;
const cropY = (crop.y / 100) * originalHeight;
ctx.scale(pixelRatio, pixelRatio);
ctx.imageSmoothingQuality = "high";
// Устанавливаем размеры канваса
canvas.width = cropWidth;
canvas.height = cropHeight;
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,
cropX, cropY, cropWidth, cropHeight, // Исходная область (x, y, width, height)
0, 0, cropWidth, cropHeight // Целевая область (x, y, width, height)
);
if (darken > 0) {

@ -30,7 +30,7 @@ export const parseAxiosError = (nativeError: unknown): [string, number?] => {
//ДЛЯ ОПЛАТЫ ТАРИФА
if(error.response.status === 402) {
console.log(error.response?.data.message)
console.error(error.response?.data.message)
return error.response?.data.message
}