From 5cde53c1d3319c65bf6c582d5107faac726c3e99 Mon Sep 17 00:00:00 2001 From: Kentai Radiquum Date: Sat, 17 Aug 2024 19:28:35 +0500 Subject: [PATCH] feat: add image crop modal --- app/api/utils.ts | 50 ++++++++++- app/components/CropModal/CropModal.tsx | 115 +++++++++++++++++++++++++ app/pages/CreateCollection.tsx | 40 +++++---- package-lock.json | 17 ++++ package.json | 1 + 5 files changed, 202 insertions(+), 21 deletions(-) create mode 100644 app/components/CropModal/CropModal.tsx diff --git a/app/api/utils.ts b/app/api/utils.ts index 0c27799..df83a8e 100644 --- a/app/api/utils.ts +++ b/app/api/utils.ts @@ -116,8 +116,22 @@ const months = [ export function unixToDate(unix: number, type: string = "short") { const date = new Date(unix * 1000); - if (type === "short") return date.getDate() + " " + months[date.getMonth()] + " " + date.getFullYear(); - if (type === "full") return date.getDate() + " " + months[date.getMonth()] + " " + date.getFullYear() + ", " + date.getHours() + ":" + date.getMinutes(); + if (type === "short") + return ( + date.getDate() + " " + months[date.getMonth()] + " " + date.getFullYear() + ); + if (type === "full") + return ( + date.getDate() + + " " + + months[date.getMonth()] + + " " + + date.getFullYear() + + ", " + + date.getHours() + + ":" + + date.getMinutes() + ); } export const getSeasonFromUnix = (unix: number) => { @@ -148,7 +162,9 @@ export function sinceUnixDate(unixInSeconds: number) { if (dateDifferenceSeconds < 86400) return `${hours} ${hoursName} назад`; if (dateDifferenceSeconds < 2592000) return `${days} ${daysName} назад`; - return date.getDate() + " " + months[date.getMonth()] + " " + date.getFullYear(); + return ( + date.getDate() + " " + months[date.getMonth()] + " " + date.getFullYear() + ); } export function minutesToTime(min: number) { @@ -248,3 +264,31 @@ export const SortList = { alphabet_descending: 5, alphabet_ascending: 6, }; + +export function b64toBlob( + b64Data: string, + contentType: string, + sliceSize?: number +) { + contentType = contentType || ""; + sliceSize = sliceSize || 512; + + var byteCharacters = atob(b64Data); + var byteArrays = []; + + for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) { + var slice = byteCharacters.slice(offset, offset + sliceSize); + + var byteNumbers = new Array(slice.length); + for (var i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + var byteArray = new Uint8Array(byteNumbers); + + byteArrays.push(byteArray); + } + + var blob = new Blob(byteArrays, { type: contentType }); + return blob; +} diff --git a/app/components/CropModal/CropModal.tsx b/app/components/CropModal/CropModal.tsx new file mode 100644 index 0000000..509d37a --- /dev/null +++ b/app/components/CropModal/CropModal.tsx @@ -0,0 +1,115 @@ +import React, { useRef } from "react"; +import Cropper, { ReactCropperElement } from "react-cropper"; +import "cropperjs/dist/cropper.css"; +import { Button, Modal } from "flowbite-react"; +import { b64toBlob } from "#/api/utils"; + +type Props = { + src: string; + setSrc: (src: string) => void; + setTempSrc: (src: string) => void; + setImageData: (src: string) => void; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + height: number; + width: number; + aspectRatio: number; + guides: boolean; + quality: number; + forceAspect?: boolean; +}; + +export const CropModal: React.FC = (props) => { + const cropperRef = useRef(null); + + const getCropData = () => { + if (typeof cropperRef.current?.cropper !== "undefined") { + props.setSrc(cropperRef.current?.cropper.getCroppedCanvas().toDataURL()); + + let block = cropperRef.current?.cropper + .getCroppedCanvas({ + width: props.width, + height: props.height, + maxWidth: props.width, + maxHeight: props.height, + }) + .toDataURL("image/jpeg", props.quality) + .split(";"); + let contentType = block[0].split(":")[1]; + let realData = block[1].split(",")[1]; + + const blob = b64toBlob(realData, contentType); + + const handleFileRead = (e, fileReader) => { + const content = fileReader.result; + props.setImageData(content); + }; + + const handleFileText = (file) => { + const fileReader = new FileReader(); + fileReader.onloadend = (e) => { + handleFileRead(e, fileReader); + }; + fileReader.readAsText(file); + }; + + handleFileText(blob); + props.setTempSrc(""); + } + }; + + return ( + props.setIsOpen(false)} + size={"7xl"} + > + Обрезать изображение + + + +
+

Управление

+

Тяните за углы что-бы выбрать область

+

+ Нажмите 2 раза на пустое место, что бы поменять режим выбора области + на перемещение и обратно +

+

Используйте колёсико мыши что-бы изменить масштаб

+
+
+ + + + +
+ ); +}; diff --git a/app/pages/CreateCollection.tsx b/app/pages/CreateCollection.tsx index 9269b56..01a5244 100644 --- a/app/pages/CreateCollection.tsx +++ b/app/pages/CreateCollection.tsx @@ -15,8 +15,8 @@ import { Label, Modal, } from "flowbite-react"; -import { ReleaseSection } from "#/components/ReleaseSection/ReleaseSection"; import { ReleaseLink } from "#/components/ReleaseLink/ReleaseLink"; +import { CropModal } from "#/components/CropModal/CropModal"; const fetcher = async (url: string) => { const res = await fetch(url); @@ -41,6 +41,7 @@ export const CreateCollectionPage = () => { const [imageData, setImageData] = useState(null); const [imageUrl, setImageUrl] = useState(null); + const [tempImageUrl, setTempImageUrl] = useState(null); const [isPrivate, setIsPrivate] = useState(false); const [collectionInfo, setCollectionInfo] = useState({ title: "", @@ -53,6 +54,7 @@ export const CreateCollectionPage = () => { const [addedReleases, setAddedReleases] = useState([]); const [addedReleasesIds, setAddedReleasesIds] = useState([]); const [releasesEditModalOpen, setReleasesEditModalOpen] = useState(false); + const [cropModalOpen, setCropModalOpen] = useState(false); const collection_id = searchParams.get("id") || null; const mode = searchParams.get("mode") || null; @@ -78,31 +80,19 @@ export const CreateCollectionPage = () => { } }, [userStore.user]); - const handleFileRead = (e, fileReader, type) => { + const handleFileRead = (e, fileReader) => { const content = fileReader.result; - if (type === "URL") { - setImageUrl(content); - } else { - setImageData(content); - } + setTempImageUrl(content); }; const handleFilePreview = (file) => { const fileReader = new FileReader(); fileReader.onloadend = (e) => { - handleFileRead(e, fileReader, "URL"); + handleFileRead(e, fileReader); }; fileReader.readAsDataURL(file); }; - const handleFileLoad = (file) => { - const fileReader = new FileReader(); - fileReader.onloadend = (e) => { - handleFileRead(e, fileReader, "TEXT"); - }; - fileReader.readAsText(file); - }; - function handleInput(e) { const regex = /[^a-zA-Zа-яА-Я0-9_.,:()!? \[\]]/g; setCollectionInfo({ @@ -195,7 +185,7 @@ export const CreateCollectionPage = () => { accept="image/jpg, image/jpeg, image/png" onChange={(e) => { handleFilePreview(e.target.files[0]); - handleFileLoad(e.target.files[0]); + setCropModalOpen(true); }} /> @@ -298,6 +288,20 @@ export const CreateCollectionPage = () => { setReleases={setAddedReleases} setReleasesIds={setAddedReleasesIds} /> + ); }; @@ -317,7 +321,7 @@ export const ReleasesEditModal = (props: { const url = new URL("/api/search", window.location.origin); url.searchParams.set("page", pageIndex.toString()); - if (!query) return null + if (!query) return null; url.searchParams.set("q", query); return url.toString(); }; diff --git a/package-lock.json b/package-lock.json index d2c7e51..fbd5abc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "markdown-to-jsx": "^7.4.7", "next": "14.2.5", "react": "^18", + "react-cropper": "^2.3.3", "react-dom": "^18", "swiper": "^11.1.4", "swr": "^2.2.5", @@ -1685,6 +1686,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cropperjs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz", + "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==" + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4563,6 +4569,17 @@ "node": ">=0.10.0" } }, + "node_modules/react-cropper": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/react-cropper/-/react-cropper-2.3.3.tgz", + "integrity": "sha512-zghiEYkUb41kqtu+2jpX2Ntigf+Jj1dF9ew4lAobPzI2adaPE31z0p+5TcWngK6TvmWQUwK3lj4G+NDh1PDQ1w==", + "dependencies": { + "cropperjs": "^1.5.13" + }, + "peerDependencies": { + "react": ">=17.0.2" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", diff --git a/package.json b/package.json index c27fc3d..eebde18 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "markdown-to-jsx": "^7.4.7", "next": "14.2.5", "react": "^18", + "react-cropper": "^2.3.3", "react-dom": "^18", "swiper": "^11.1.4", "swr": "^2.2.5",