diff --git a/package.json b/package.json index fae246be8..0a8dd9ee9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@emotion/react": "11.11.1", "@emotion/styled": "11.11.0", "@graasp/chatbox": "3.0.1", - "@graasp/query-client": "2.3.0", + "@graasp/query-client": "2.3.2", "@graasp/sdk": "3.4.1", "@graasp/translations": "1.22.1", "@graasp/ui": "4.3.1", @@ -55,7 +55,7 @@ "react-dom": "18.2.0", "react-ga4": "2.1.0", "react-i18next": "13.5.0", - "react-image-crop": "9.1.1", + "react-image-crop": "11.0.5", "react-qr-code": "2.0.12", "react-query": "3.39.3", "react-quill": "2.0.0", diff --git a/src/components/common/CropModal.tsx b/src/components/common/CropModal.tsx index 139f5c72e..2ef47a6a8 100644 --- a/src/components/common/CropModal.tsx +++ b/src/components/common/CropModal.tsx @@ -1,5 +1,10 @@ -import { useRef, useState } from 'react'; -import ReactCrop, { Crop, PixelCrop } from 'react-image-crop'; +import { ReactEventHandler, useRef, useState } from 'react'; +import ReactCrop, { + Crop, + PixelCrop, + centerCrop, + makeAspectCrop, +} from 'react-image-crop'; import 'react-image-crop/dist/ReactCrop.css'; import Dialog from '@mui/material/Dialog'; @@ -12,12 +17,30 @@ import { Button } from '@graasp/ui'; import { THUMBNAIL_ASPECT } from '../../config/constants'; import { useBuilderTranslation } from '../../config/i18n'; -import notifier from '../../config/notifier'; import { CROP_MODAL_CONFIRM_BUTTON_CLASSNAME } from '../../config/selectors'; import { BUILDER } from '../../langs/constants'; -import { getCroppedImg } from '../../utils/image'; import CancelButton from './CancelButton'; +function centerAspectCrop( + mediaWidth: number, + mediaHeight: number, + aspect: number, +) { + return centerCrop( + makeAspectCrop( + { + unit: '%', + width: 90, + }, + aspect, + mediaWidth, + mediaHeight, + ), + mediaWidth, + mediaHeight, + ); +} + export type CropProps = { open: boolean; onClose: () => void; @@ -31,68 +54,83 @@ const CropModal = ({ onClose, src, }: CropProps): JSX.Element => { - const [crop, setCrop] = useState(); - const imageRef = useRef(); + const [crop, setCrop] = useState(); + const [completedCrop, setCompletedCrop] = useState(); + const imageRef = useRef(null); const { t } = useBuilderTranslation(); - const makeClientCrop = async (newCrop: PixelCrop) => { - if (imageRef.current && newCrop.width && newCrop.height) { - const croppedImage = await getCroppedImg(imageRef.current, newCrop); - return croppedImage; + const handleOnConfirm = async () => { + const image = imageRef.current; + if (!image || !completedCrop) { + throw new Error('Crop canvas does not exist'); } - return null; - }; - const handleOnConfirm = async () => { - if (!crop) { - notifier({ - type: 'crop', - payload: { error: new Error('crop is undefined') }, - }); - } else { - const final = await makeClientCrop(crop); - onConfirm(final); + const offscreen = new OffscreenCanvas( + completedCrop.width, + completedCrop.height, + ); + const ctx = offscreen.getContext('2d'); + if (!ctx) { + throw new Error('No 2d context'); } + + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + // devicePixelRatio slightly increases sharpness on retina devices + // at the expense of slightly slower render times and needing to + // size the image back down if you want to download/upload and be + // true to the images natural size. + const pixelRatio = window.devicePixelRatio; + // const pixelRatio = 1 + + offscreen.width = Math.floor(completedCrop.width * scaleX * pixelRatio); + offscreen.height = Math.floor(completedCrop.height * scaleY * pixelRatio); + + ctx.scale(pixelRatio, pixelRatio); + ctx.imageSmoothingQuality = 'high'; + + const cropX = completedCrop.x * scaleX; + const cropY = completedCrop.y * scaleY; + + const centerX = image.naturalWidth / 2; + const centerY = image.naturalHeight / 2; + + ctx.save(); + + // 5) Move the crop origin to the canvas origin (0,0) + ctx.translate(-cropX, -cropY); + // 4) Move the origin to the center of the original position + ctx.translate(centerX, centerY); + // 1) Move the center of the image to the origin (0,0) + ctx.translate(-centerX, -centerY); + ctx.drawImage( + image, + 0, + 0, + image.naturalWidth, + image.naturalHeight, + 0, + 0, + image.naturalWidth, + image.naturalHeight, + ); + + // You might want { type: "image/jpeg", quality: <0 to 1> } to + // reduce image size + const blob = await offscreen.convertToBlob({ + type: 'image/png', + }); + onConfirm(blob); }; // If you setState the crop in here you should return false. - const onImageLoaded = (img: HTMLImageElement) => { + const onImageLoaded: ReactEventHandler = (event) => { if (!imageRef.current) { - imageRef.current = img; + imageRef.current = event.currentTarget; } - const { width: imgWidth, height: imgHeight } = img; - - const aspect = THUMBNAIL_ASPECT; - const outputImageAspectRatio = aspect; - const inputImageAspectRatio = imgWidth / imgHeight; - - let width = imgWidth; - let height = imgHeight; - // if it's bigger than our target aspect ratio - if (inputImageAspectRatio > outputImageAspectRatio) { - width = imgHeight * outputImageAspectRatio; - } else if (inputImageAspectRatio < outputImageAspectRatio) { - height = imgWidth / outputImageAspectRatio; - } - - const y = Math.floor((imgHeight - height) / 2); - const x = Math.floor((imgWidth - width) / 2); - - const newCrop = { - width, - height, - x, - y, - aspect, - unit: 'px', - } as PixelCrop; - setCrop(newCrop); - return false; // Return false if you set crop state in here. - }; - - const onCropChange = (newCrop: Crop, _percentageCrop: Crop) => { - setCrop(newCrop as PixelCrop); + const { width: imgWidth, height: imgHeight } = event.currentTarget; + setCrop(centerAspectCrop(imgWidth, imgHeight, THUMBNAIL_ASPECT)); }; const label = 'crop-modal-title'; @@ -105,12 +143,22 @@ const CropModal = ({ {t(BUILDER.CROP_IMAGE_MODAL_CONTENT_TEXT)} setCrop(percentageCrop)} + onComplete={(c) => setCompletedCrop(c)} + aspect={THUMBNAIL_ASPECT} + // circularCrop ruleOfThirds - onImageLoaded={onImageLoaded} - onChange={onCropChange} - /> + > + Crop me + diff --git a/src/utils/image.ts b/src/utils/image.ts index 498fad645..266ff0bba 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -1,11 +1,11 @@ -import { PixelCrop } from 'react-image-crop'; +import { Crop } from 'react-image-crop'; import { THUMBNAIL_EXTENSION } from '../config/constants'; // eslint-disable-next-line import/prefer-default-export export const getCroppedImg = ( image: HTMLImageElement, - crop: PixelCrop, + crop: Crop, extension = THUMBNAIL_EXTENSION, ): Promise => { const canvas = document.createElement('canvas'); diff --git a/yarn.lock b/yarn.lock index a972607ef..ce3bb82a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1233,11 +1233,11 @@ __metadata: languageName: node linkType: hard -"@graasp/query-client@npm:2.3.0": - version: 2.3.0 - resolution: "@graasp/query-client@npm:2.3.0" +"@graasp/query-client@npm:2.3.2": + version: 2.3.2 + resolution: "@graasp/query-client@npm:2.3.2" dependencies: - "@graasp/sdk": "npm:3.4.1" + "@graasp/sdk": "npm:3.5.0" "@graasp/translations": "npm:1.22.1" axios: "npm:0.27.2" crypto-js: "npm:4.2.0" @@ -1247,7 +1247,7 @@ __metadata: uuid: "npm:9.0.1" peerDependencies: react: ^17.0.0 || ^18.0.0 - checksum: 7cbf30cc71c27e19b50d896899b2bed3de924c4d7b0d09346198ee07c0d640261908fdb718cce11b00888f2333b581e175ca2d31bac766163b27ef069a29abfa + checksum: a998abfc7a826ce789c32e8d7112811a2e6f0e22a41ff476d5939bb35a6eac22b6167e32c8c47b5100fe889b26612f71cf83bb439be18f9f13a4e8e3e5b8644c languageName: node linkType: hard @@ -1276,6 +1276,18 @@ __metadata: languageName: node linkType: hard +"@graasp/sdk@npm:3.5.0": + version: 3.5.0 + resolution: "@graasp/sdk@npm:3.5.0" + dependencies: + date-fns: "npm:3.2.0" + js-cookie: "npm:3.0.5" + uuid: "npm:9.0.1" + validator: "npm:13.11.0" + checksum: dfbb9977904336214e411c93a33084fca8e07cb73145f655a070dfdb81a772d6a0412849ee07c8ee2e74497d59f960a8ef1989062a4e41d78ad5d22b24e8a8db + languageName: node + linkType: hard + "@graasp/translations@npm:1.22.1": version: 1.22.1 resolution: "@graasp/translations@npm:1.22.1" @@ -6310,7 +6322,7 @@ __metadata: "@emotion/react": "npm:11.11.1" "@emotion/styled": "npm:11.11.0" "@graasp/chatbox": "npm:3.0.1" - "@graasp/query-client": "npm:2.3.0" + "@graasp/query-client": "npm:2.3.2" "@graasp/sdk": "npm:3.4.1" "@graasp/translations": "npm:1.22.1" "@graasp/ui": "npm:4.3.1" @@ -6383,7 +6395,7 @@ __metadata: react-dom: "npm:18.2.0" react-ga4: "npm:2.1.0" react-i18next: "npm:13.5.0" - react-image-crop: "npm:9.1.1" + react-image-crop: "npm:11.0.5" react-qr-code: "npm:2.0.12" react-query: "npm:3.39.3" react-quill: "npm:2.0.0" @@ -9833,14 +9845,12 @@ __metadata: languageName: node linkType: hard -"react-image-crop@npm:9.1.1": - version: 9.1.1 - resolution: "react-image-crop@npm:9.1.1" - dependencies: - clsx: "npm:^1.1.1" +"react-image-crop@npm:11.0.5": + version: 11.0.5 + resolution: "react-image-crop@npm:11.0.5" peerDependencies: react: ">=16.13.1" - checksum: 806d809f965f111e3e792d99d316b2786f9d90113a6abcfc5b20f6156d669dbd194970eece39a6a490b42330e1b05f25bc4215ece7d41187d57e9eecaecebd99 + checksum: 0af15aa70758949797c992b0c1d21a398185e36a7e829d241cfc4bcfe679dd22bd4d0a0a74db78b079c3760315067ff4156dd2d0ecffa6b62a8e87f067e0f271 languageName: node linkType: hard