fix crop modal
This commit is contained in:
parent
859987445b
commit
e77e83d5d4
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user