Hey is a decentralized and permissionless social media app built with Lens Protocol 🌿

🌾 Replaced @hey/image-cropper with react-easy-crop (#migrate-to-r-easy-crop)

Summary: Replaced `@hey/image-cropper` with `react-easy-crop` for image cropping in `AvatarUpload` and `CoverUpload`.

Highlights:

• Removed `@hey/image-cropper` package and its dependencies from `package.json`.
• Updated `AvatarUpload` and `CoverUpload` components to use `react-easy-crop`.
• Deleted `ImageCropperController` and related files from the codebase.

Read more: https://pierre.co/yo/hey/migrate-to-r-easy-crop

authored by yoginth.com and committed by

Pierre 9e09ca41 bbcb6982

+149 -1028
+1 -3
apps/web/package.json
··· 18 18 "@hey/data": "workspace:*", 19 19 "@hey/db": "workspace:*", 20 20 "@hey/helpers": "workspace:*", 21 - "@hey/image-cropper": "workspace:*", 22 21 "@hey/indexer": "workspace:*", 23 22 "@hey/rpc": "workspace:*", 24 23 "@hey/ui": "workspace:*", ··· 41 40 "plur": "^5.1.0", 42 41 "plyr-react": "^5.3.0", 43 42 "prosekit": "^0.12.1", 44 - "rc-slider": "^11.1.8", 45 43 "react": "^19.0.0", 46 44 "react-chartjs-2": "^5.3.0", 47 45 "react-device-detect": "^2.2.3", 48 46 "react-dom": "^19.0.0", 47 + "react-easy-crop": "^5.4.1", 49 48 "react-hot-toast": "^2.5.2", 50 49 "react-markdown": "^10.1.0", 51 50 "react-tracked": "^2.0.1", ··· 60 59 "strip-markdown": "^6.0.0", 61 60 "unified": "^11.0.5", 62 61 "unist-util-visit-parents": "^6.0.1", 63 - "use-resize-observer": "^9.1.0", 64 62 "viem": "^2.23.15", 65 63 "wagmi": "^2.14.15", 66 64 "zod": "^3.24.2",
+40 -29
apps/web/src/components/Shared/AvatarUpload.tsx
··· 1 1 import ChooseFile from "@/components/Shared/ChooseFile"; 2 - import ImageCropperController from "@/components/Shared/ImageCropperController"; 3 2 import uploadCroppedImage, { readFile } from "@/helpers/accountPictureUtils"; 3 + import getCroppedImg from "@/helpers/cropUtils"; 4 4 import errorToast from "@/helpers/errorToast"; 5 5 import { AVATAR, DEFAULT_AVATAR } from "@hey/data/constants"; 6 6 import { Errors } from "@hey/data/errors"; 7 7 import imageKit from "@hey/helpers/imageKit"; 8 8 import sanitizeDStorageUrl from "@hey/helpers/sanitizeDStorageUrl"; 9 - import { getCroppedImg } from "@hey/image-cropper/cropUtils"; 10 - import type { Area } from "@hey/image-cropper/types"; 11 9 import { Button, Image, Modal } from "@hey/ui"; 12 10 import cn from "@hey/ui/cn"; 13 11 import type { ChangeEvent } from "react"; 14 12 import { useState } from "react"; 13 + import Cropper, { type Area } from "react-easy-crop"; 15 14 import toast from "react-hot-toast"; 16 15 17 16 interface AvatarUploadProps { ··· 23 22 const AvatarUpload = ({ src, setSrc, isSmall = false }: AvatarUploadProps) => { 24 23 const [isSubmitting, setIsSubmitting] = useState(false); 25 24 const [pictureSrc, setPictureSrc] = useState(src); 26 - const [showPictureCropModal, setShowPictureCropModal] = useState(false); 27 - const [croppedPictureAreaPixels, setCroppedPictureAreaPixels] = 28 - useState<Area | null>(null); 29 - const [uploadedPictureUrl, setUploadedPictureUrl] = useState(""); 30 - const [uploadingPicture, setUploadingPicture] = useState(false); 25 + const [showModal, setShowModal] = useState(false); 26 + const [uploadedPicture, setUploadedPicture] = useState(""); 27 + const [uploading, setUploading] = useState(false); 28 + const [area, setArea] = useState<Area | null>(null); 29 + const [crop, setCrop] = useState({ x: 0, y: 0 }); 30 + const [zoom, setZoom] = useState(1); 31 31 32 32 const onError = (error: any) => { 33 33 setIsSubmitting(false); ··· 36 36 37 37 const handleUploadAndSave = async () => { 38 38 try { 39 - const croppedImage = await getCroppedImg( 40 - pictureSrc, 41 - croppedPictureAreaPixels 42 - ); 39 + setUploading(true); 40 + const croppedImage = await getCroppedImg(pictureSrc, area); 43 41 44 42 if (!croppedImage) { 45 43 return toast.error(Errors.SomethingWentWrong); 46 44 } 47 45 48 - setUploadingPicture(true); 49 - 50 46 const decentralizedUrl = await uploadCroppedImage(croppedImage); 51 47 const dataUrl = croppedImage.toDataURL("image/png"); 52 48 53 49 setSrc(decentralizedUrl); 54 - setUploadedPictureUrl(dataUrl); 50 + setUploadedPicture(dataUrl); 55 51 } catch (error) { 56 52 onError(error); 57 53 } finally { 58 - setShowPictureCropModal(false); 59 - setUploadingPicture(false); 54 + setArea(null); 55 + setCrop({ x: 0, y: 0 }); 56 + setZoom(1); 57 + setShowModal(false); 58 + setUploading(false); 60 59 } 61 60 }; 62 61 ··· 64 63 const file = evt.target.files?.[0]; 65 64 if (file) { 66 65 setPictureSrc(await readFile(file)); 67 - setShowPictureCropModal(true); 66 + setShowModal(true); 68 67 } 69 68 }; 70 69 70 + const onCropComplete = (_: Area, croppedAreaPixels: Area) => { 71 + setArea(croppedAreaPixels); 72 + }; 73 + 71 74 const pictureUrl = pictureSrc || DEFAULT_AVATAR; 72 75 const renderPictureUrl = pictureUrl 73 76 ? imageKit(sanitizeDStorageUrl(pictureUrl), AVATAR) ··· 84 87 onError={({ currentTarget }) => { 85 88 currentTarget.src = sanitizeDStorageUrl(src); 86 89 }} 87 - src={uploadedPictureUrl || renderPictureUrl} 90 + src={uploadedPicture || renderPictureUrl} 88 91 /> 89 92 <ChooseFile onChange={(event) => onFileChange(event)} /> 90 93 </div> ··· 95 98 ? undefined 96 99 : () => { 97 100 setPictureSrc(""); 98 - setShowPictureCropModal(false); 101 + setShowModal(false); 99 102 } 100 103 } 101 - show={showPictureCropModal} 102 - size="sm" 104 + show={showModal} 105 + size="xs" 103 106 title="Crop picture" 104 107 > 105 - <div className="p-5 text-right"> 106 - <ImageCropperController 107 - imageSrc={pictureSrc} 108 - setCroppedAreaPixels={setCroppedPictureAreaPixels} 109 - targetSize={{ height: 300, width: 300 }} 110 - /> 108 + <div className="space-y-5 p-5"> 109 + <div className="relative flex size-64 w-full"> 110 + <Cropper 111 + cropShape="round" 112 + image={pictureSrc} 113 + crop={crop} 114 + zoom={zoom} 115 + aspect={5 / 5} 116 + onCropChange={setCrop} 117 + onCropComplete={onCropComplete} 118 + onZoomChange={setZoom} 119 + /> 120 + </div> 111 121 <Button 112 - disabled={uploadingPicture || !pictureSrc} 122 + className="w-full" 123 + disabled={uploading || !pictureSrc} 113 124 onClick={handleUploadAndSave} 114 125 type="submit" 115 126 >
+37 -28
apps/web/src/components/Shared/CoverUpload.tsx
··· 1 1 import ChooseFile from "@/components/Shared/ChooseFile"; 2 - import ImageCropperController from "@/components/Shared/ImageCropperController"; 3 2 import uploadCroppedImage, { readFile } from "@/helpers/accountPictureUtils"; 3 + import getCroppedImg from "@/helpers/cropUtils"; 4 4 import errorToast from "@/helpers/errorToast"; 5 5 import { InformationCircleIcon } from "@heroicons/react/24/outline"; 6 6 import { COVER, STATIC_IMAGES_URL } from "@hey/data/constants"; 7 7 import { Errors } from "@hey/data/errors"; 8 8 import imageKit from "@hey/helpers/imageKit"; 9 9 import sanitizeDStorageUrl from "@hey/helpers/sanitizeDStorageUrl"; 10 - import { getCroppedImg } from "@hey/image-cropper/cropUtils"; 11 - import type { Area } from "@hey/image-cropper/types"; 12 10 import { Button, Image, Modal } from "@hey/ui"; 13 11 import type { ChangeEvent } from "react"; 14 12 import { useState } from "react"; 13 + import Cropper, { type Area } from "react-easy-crop"; 15 14 import toast from "react-hot-toast"; 16 15 17 16 interface CoverUploadProps { ··· 22 21 const CoverUpload = ({ src, setSrc }: CoverUploadProps) => { 23 22 const [isSubmitting, setIsSubmitting] = useState(false); 24 23 const [pictureSrc, setPictureSrc] = useState(src); 25 - const [showPictureCropModal, setShowPictureCropModal] = useState(false); 26 - const [croppedPictureAreaPixels, setPictureCroppedAreaPixels] = 27 - useState<Area | null>(null); 28 - const [uploadedPictureUrl, setUploadedPictureUrl] = useState(""); 29 - const [uploadingPicture, setUploadingPicture] = useState(false); 24 + const [showModal, setShowModal] = useState(false); 25 + const [uploadedPicture, setUploadedPicture] = useState(""); 26 + const [uploading, setUploading] = useState(false); 27 + const [area, setArea] = useState<Area | null>(null); 28 + const [crop, setCrop] = useState({ x: 0, y: 0 }); 29 + const [zoom, setZoom] = useState(1); 30 30 31 31 const onError = (error: any) => { 32 32 setIsSubmitting(false); ··· 35 35 36 36 const handleUploadAndSave = async () => { 37 37 try { 38 - const croppedImage = await getCroppedImg( 39 - pictureSrc, 40 - croppedPictureAreaPixels 41 - ); 38 + setUploading(true); 39 + const croppedImage = await getCroppedImg(pictureSrc, area); 42 40 43 41 if (!croppedImage) { 44 42 return toast.error(Errors.SomethingWentWrong); 45 43 } 46 44 47 - setUploadingPicture(true); 48 - 49 45 const decentralizedUrl = await uploadCroppedImage(croppedImage); 50 46 const dataUrl = croppedImage.toDataURL("image/png"); 51 47 52 48 setSrc(decentralizedUrl); 53 - setUploadedPictureUrl(dataUrl); 49 + setUploadedPicture(dataUrl); 54 50 } catch (error) { 55 51 onError(error); 56 52 } finally { 57 - setShowPictureCropModal(false); 58 - setUploadingPicture(false); 53 + setArea(null); 54 + setCrop({ x: 0, y: 0 }); 55 + setZoom(1); 56 + setShowModal(false); 57 + setUploading(false); 59 58 } 60 59 }; 61 60 ··· 63 62 const file = evt.target.files?.[0]; 64 63 if (file) { 65 64 setPictureSrc(await readFile(file)); 66 - setShowPictureCropModal(true); 65 + setShowModal(true); 67 66 } 67 + }; 68 + 69 + const onCropComplete = (_: Area, croppedAreaPixels: Area) => { 70 + setArea(croppedAreaPixels); 68 71 }; 69 72 70 73 const pictureUrl = pictureSrc || `${STATIC_IMAGES_URL}/patterns/2.svg`; ··· 84 87 onError={({ currentTarget }) => { 85 88 currentTarget.src = sanitizeDStorageUrl(src); 86 89 }} 87 - src={uploadedPictureUrl || renderPictureUrl} 90 + src={uploadedPicture || renderPictureUrl} 88 91 /> 89 92 </div> 90 93 <ChooseFile onChange={(event) => onFileChange(event)} /> ··· 96 99 ? undefined 97 100 : () => { 98 101 setPictureSrc(""); 99 - setShowPictureCropModal(false); 102 + setShowModal(false); 100 103 } 101 104 } 102 - show={showPictureCropModal} 105 + show={showModal} 103 106 size="lg" 104 107 title="Crop cover picture" 105 108 > 106 - <div className="p-5 text-right"> 107 - <ImageCropperController 108 - imageSrc={pictureSrc} 109 - setCroppedAreaPixels={setPictureCroppedAreaPixels} 110 - targetSize={{ height: 350, width: 1350 }} 111 - /> 109 + <div className="space-y-5 p-5"> 110 + <div className="relative flex size-64 w-full"> 111 + <Cropper 112 + image={pictureSrc} 113 + crop={crop} 114 + zoom={zoom} 115 + aspect={1350 / 350} 116 + onCropChange={setCrop} 117 + onCropComplete={onCropComplete} 118 + onZoomChange={setZoom} 119 + /> 120 + </div> 112 121 <div className="flex w-full flex-wrap items-center justify-between gap-y-3"> 113 122 <div className="ld-text-gray-500 flex items-center space-x-1 text-left text-sm"> 114 123 <InformationCircleIcon className="size-4" /> ··· 117 126 </div> 118 127 </div> 119 128 <Button 120 - disabled={uploadingPicture || !pictureSrc} 129 + disabled={uploading || !pictureSrc} 121 130 onClick={handleUploadAndSave} 122 131 type="submit" 123 132 >
-85
apps/web/src/components/Shared/ImageCropperController.tsx
··· 1 - import { 2 - MagnifyingGlassMinusIcon, 3 - MagnifyingGlassPlusIcon 4 - } from "@heroicons/react/24/outline"; 5 - import ImageCropper from "@hey/image-cropper/ImageCropper"; 6 - import type { Area, Point, Size } from "@hey/image-cropper/types"; 7 - import Slider from "rc-slider"; 8 - import "rc-slider/assets/index.css"; 9 - import type { Dispatch } from "react"; 10 - import { useEffect, useRef, useState } from "react"; 11 - import useResizeObserver from "use-resize-observer"; 12 - 13 - interface ImageCropperControllerProps { 14 - imageSrc: string; 15 - setCroppedAreaPixels: Dispatch<Area>; 16 - targetSize: Size; 17 - } 18 - 19 - const ImageCropperController = ({ 20 - imageSrc, 21 - setCroppedAreaPixels, 22 - targetSize 23 - }: ImageCropperControllerProps) => { 24 - const [crop, setCrop] = useState<Point>({ x: 0, y: 0 }); 25 - const [zoom, setZoom] = useState(1); 26 - const [maxZoom, setMaxZoom] = useState(1); 27 - const cropperRef = useRef<ImageCropper>(null); 28 - const [cropSize, setCropSize] = useState<Size>(targetSize); 29 - const { ref: divRef, width: divWidth = cropSize.width } = 30 - useResizeObserver<HTMLDivElement>(); 31 - 32 - const aspectRatio = targetSize.width / targetSize.height; 33 - const borderSize = 20; 34 - 35 - useEffect(() => { 36 - const newWidth = divWidth - borderSize * 2; 37 - const newHeight = newWidth / aspectRatio; 38 - setCropSize({ height: newHeight, width: newWidth }); 39 - }, [divWidth, borderSize, aspectRatio]); 40 - 41 - const onSliderChange = (value: number | number[]) => { 42 - const logarithmicZoomValue = Array.isArray(value) ? value[0] : value; 43 - const zoomValue = Math.exp(logarithmicZoomValue); 44 - setZoom(zoomValue); 45 - cropperRef.current?.setNewZoom(zoomValue, null); 46 - }; 47 - 48 - return ( 49 - <div ref={divRef}> 50 - <ImageCropper 51 - borderSize={borderSize} 52 - cropPositionPercent={crop} 53 - cropSize={cropSize} 54 - image={imageSrc} 55 - onCropChange={setCrop} 56 - onCropComplete={setCroppedAreaPixels} 57 - onZoomChange={(zoomValue, maxZoomValue) => { 58 - setZoom(zoomValue); 59 - setMaxZoom(maxZoomValue); 60 - }} 61 - ref={cropperRef} 62 - targetSize={targetSize} 63 - zoom={zoom} 64 - zoomSpeed={1.2} 65 - /> 66 - <div 67 - className="flex py-2" 68 - style={{ width: cropSize.width + borderSize * 2 }} 69 - > 70 - <MagnifyingGlassMinusIcon className="m-1 size-6" /> 71 - <Slider 72 - className="m-2 flex-grow" 73 - max={Math.log(maxZoom)} 74 - min={0} 75 - onChange={onSliderChange} 76 - step={0.001} 77 - value={Math.log(zoom)} 78 - /> 79 - <MagnifyingGlassPlusIcon className="m-1 size-6" /> 80 - </div> 81 - </div> 82 - ); 83 - }; 84 - 85 - export default ImageCropperController;
+40
apps/web/src/helpers/cropUtils.ts
··· 1 + import type { Area } from "react-easy-crop"; 2 + 3 + const createImage = (url: string): Promise<HTMLImageElement> => 4 + new Promise((resolve, reject) => { 5 + const image = new Image(); 6 + image.addEventListener("load", () => resolve(image)); 7 + image.addEventListener("error", (error) => reject(error)); 8 + image.src = url; 9 + }); 10 + 11 + const getCroppedImg = async ( 12 + imageSrc: string, 13 + pixelCrop: Area | null 14 + ): Promise<HTMLCanvasElement | null> => { 15 + const image = await createImage(imageSrc); 16 + const canvas = document.createElement("canvas"); 17 + const ctx = canvas.getContext("2d"); 18 + if (!ctx || !pixelCrop) { 19 + return null; 20 + } 21 + 22 + canvas.width = image.width; 23 + canvas.height = image.height; 24 + ctx.drawImage(image, 0, 0); 25 + 26 + const data = ctx.getImageData( 27 + pixelCrop.x, 28 + pixelCrop.y, 29 + pixelCrop.width, 30 + pixelCrop.height 31 + ); 32 + 33 + canvas.width = pixelCrop.width; 34 + canvas.height = pixelCrop.height; 35 + ctx.putImageData(data, 0, 0); 36 + 37 + return canvas; 38 + }; 39 + 40 + export default getCroppedImg;
-19
apps/web/src/styles.css
··· 1 - @import url("@hey/image-cropper/styles.css"); 2 - 3 1 @tailwind base; 4 2 @tailwind components; 5 3 @tailwind utilities; ··· 130 128 131 129 #typeahead-menu { 132 130 @apply z-20; 133 - } 134 - 135 - .rc-slider-rail { 136 - @apply bg-black dark:bg-white !important; 137 - @apply opacity-30; 138 - } 139 - 140 - .rc-slider-track { 141 - @apply bg-black dark:bg-white !important; 142 - } 143 - 144 - .rc-slider-handle { 145 - @apply border-black dark:border-white !important; 146 - } 147 - 148 - .rc-slider-handle-dragging { 149 - box-shadow: 0 0 0 5px rgb(139 92 246 / 0.3) !important; 150 131 } 151 132 152 133 /* Markup styles */
-535
packages/image-cropper/ImageCropper.tsx
··· 1 - import cn from "@hey/ui/cn"; 2 - import normalizeWheel from "normalize-wheel"; 3 - import type { RefObject } from "react"; 4 - import { Component, createRef } from "react"; 5 - import { 6 - computeCroppedArea, 7 - getDistanceBetweenPoints, 8 - getMidpoint, 9 - restrictPosition, 10 - restrictValue 11 - } from "./cropUtils"; 12 - import type { Area, MediaSize, Point, Size } from "./types"; 13 - 14 - interface CropperProps { 15 - borderSize: number; 16 - cropPositionPercent: Point; 17 - cropSize: Size; 18 - image?: string; 19 - onCropChange: (location: Point) => void; 20 - onCropComplete?: (croppedAreaPixels: Area) => void; 21 - onZoomChange?: (zoom: number, maxZoom: number) => void; 22 - targetSize: Size; 23 - zoom: number; 24 - zoomSpeed: number; 25 - } 26 - 27 - type State = { 28 - hasWheelJustStarted: boolean; 29 - }; 30 - 31 - type GestureEvent = { 32 - clientX: number; 33 - clientY: number; 34 - scale: number; 35 - } & UIEvent; 36 - 37 - class ImageCropper extends Component<CropperProps, State> { 38 - static defaultProps = { 39 - zoom: 1, 40 - zoomSpeed: 1 41 - }; 42 - 43 - static getMousePoint = ( 44 - event: GestureEvent | MouseEvent | React.MouseEvent 45 - ) => ({ 46 - x: Number(event.clientX), 47 - y: Number(event.clientY) 48 - }); 49 - static getTouchPoint = (touch: React.Touch | Touch) => ({ 50 - x: Number(touch.clientX), 51 - y: Number(touch.clientY) 52 - }); 53 - cleanEvents = () => { 54 - this.currentDoc.removeEventListener("mousemove", this.onMouseMove); 55 - this.currentDoc.removeEventListener("mouseup", this.onDragStopped); 56 - this.currentDoc.removeEventListener("touchmove", this.onTouchMove); 57 - this.currentDoc.removeEventListener("touchend", this.onDragStopped); 58 - this.currentDoc.removeEventListener( 59 - "gesturemove", 60 - this.onGestureMove as EventListener 61 - ); 62 - this.currentDoc.removeEventListener( 63 - "gestureend", 64 - this.onGestureEnd as EventListener 65 - ); 66 - }; 67 - clearScrollEvent = () => { 68 - if (this.containerRef) { 69 - this.containerRef.removeEventListener("wheel", this.onWheel); 70 - } 71 - if (this.wheelTimer) { 72 - clearTimeout(this.wheelTimer); 73 - } 74 - }; 75 - computeSizes = () => { 76 - const mediaRef = this.imageRef.current; 77 - if (mediaRef && this.containerRef) { 78 - const naturalWidth = this.imageRef.current?.naturalWidth || 0; 79 - const naturalHeight = this.imageRef.current?.naturalHeight || 0; 80 - const mediaAspect = naturalWidth / naturalHeight; 81 - const fitWidth = 82 - naturalWidth / naturalHeight < 83 - this.props.cropSize.width / this.props.cropSize.height; 84 - const renderedMediaSize: Size = fitWidth 85 - ? { 86 - height: this.props.cropSize.width / mediaAspect, 87 - width: this.props.cropSize.width 88 - } 89 - : { 90 - height: this.props.cropSize.height, 91 - width: this.props.cropSize.height * mediaAspect 92 - }; 93 - 94 - this.mediaSize = { 95 - ...renderedMediaSize, 96 - naturalHeight, 97 - naturalWidth 98 - }; 99 - const cropSize = { 100 - height: this.props.cropSize.height, 101 - width: this.props.cropSize.width 102 - }; 103 - this.recomputeCropPosition(); 104 - return cropSize; 105 - } 106 - }; 107 - containerRef: HTMLDivElement | null = null; 108 - currentDoc: Document = document; 109 - currentWindow: Window = window; 110 - dragStartCrop: Point = { x: 0, y: 0 }; 111 - dragStartPosition: Point = { x: 0, y: 0 }; 112 - emitCropData = () => { 113 - const cropData = this.getCropData(); 114 - if (!cropData) { 115 - return; 116 - } 117 - 118 - const { croppedAreaPixels } = cropData; 119 - if (this.props.onCropComplete) { 120 - this.props.onCropComplete(croppedAreaPixels); 121 - } 122 - }; 123 - gestureZoomStart = 0; 124 - getAbsolutePosition = (percentPosition: Point): Point => { 125 - const x = (this.mediaSize.width * percentPosition.x) / 100; 126 - const y = (this.mediaSize.height * percentPosition.y) / 100; 127 - return { x, y }; 128 - }; 129 - 130 - getCropData = () => { 131 - // ensure the crop is correctly restricted after a zoom back (https://github.com/ValentinH/react-easy-crop/issues/6) 132 - const cropPosition = this.getAbsolutePosition( 133 - this.props.cropPositionPercent 134 - ); 135 - const restrictedPosition = restrictPosition( 136 - cropPosition, 137 - this.mediaSize, 138 - this.props.cropSize, 139 - this.props.zoom 140 - ); 141 - return computeCroppedArea( 142 - restrictedPosition, 143 - this.props.cropSize, 144 - this.mediaSize, 145 - this.props.zoom 146 - ); 147 - }; 148 - 149 - getPercentPosition = (absolutePosition: Point): Point => { 150 - const x = (absolutePosition.x / this.mediaSize.width) * 100; 151 - const y = (absolutePosition.y / this.mediaSize.height) * 100; 152 - return { x, y }; 153 - }; 154 - 155 - getPointOnContainer = ({ x, y }: Point) => { 156 - const containerRect = this.containerRef?.getBoundingClientRect(); 157 - if (!containerRect) { 158 - throw new Error("The Cropper is not mounted"); 159 - } 160 - return { 161 - x: containerRect.width / 2 - (x - containerRect.left), 162 - y: containerRect.height / 2 - (y - containerRect.top) 163 - }; 164 - }; 165 - 166 - getPointOnMedia = ({ x, y }: Point) => { 167 - const cropPosition = this.getAbsolutePosition( 168 - this.props.cropPositionPercent 169 - ); 170 - const { zoom } = this.props; 171 - return { 172 - x: (x + cropPosition.x) / zoom, 173 - y: (y + cropPosition.y) / zoom 174 - }; 175 - }; 176 - 177 - imageRef: RefObject<HTMLImageElement> = createRef() as any; 178 - 179 - isTouching = false; 180 - 181 - lastPinchDistance = 0; 182 - 183 - mediaSize: MediaSize = { 184 - height: 0, 185 - naturalHeight: 0, 186 - naturalWidth: 0, 187 - width: 0 188 - }; 189 - 190 - onDrag = ({ x, y }: Point) => { 191 - if (this.rafDragTimeout) { 192 - this.currentWindow.cancelAnimationFrame(this.rafDragTimeout); 193 - } 194 - 195 - this.rafDragTimeout = this.currentWindow.requestAnimationFrame(() => { 196 - if (x === undefined || y === undefined) { 197 - return; 198 - } 199 - const offsetX = x - this.dragStartPosition.x; 200 - const offsetY = y - this.dragStartPosition.y; 201 - const requestedPosition = { 202 - x: this.dragStartCrop.x + offsetX, 203 - y: this.dragStartCrop.y + offsetY 204 - }; 205 - 206 - const newPosition = restrictPosition( 207 - requestedPosition, 208 - this.mediaSize, 209 - this.props.cropSize, 210 - this.props.zoom 211 - ); 212 - const newPercentPosition = this.getPercentPosition(newPosition); 213 - this.props.onCropChange(newPercentPosition); 214 - }); 215 - }; 216 - 217 - onDragStart = ({ x, y }: Point) => { 218 - this.dragStartPosition = { x, y }; 219 - this.dragStartCrop = { 220 - ...this.getAbsolutePosition(this.props.cropPositionPercent) 221 - }; 222 - }; 223 - 224 - onDragStopped = () => { 225 - this.isTouching = false; 226 - this.cleanEvents(); 227 - this.emitCropData(); 228 - }; 229 - 230 - onGestureEnd = () => { 231 - this.cleanEvents(); 232 - }; 233 - 234 - onGestureMove = (event: GestureEvent) => { 235 - event.preventDefault(); 236 - if (this.isTouching) { 237 - // avoid conflict between gesture and touch events 238 - return; 239 - } 240 - 241 - const point = ImageCropper.getMousePoint(event); 242 - const newZoom = this.gestureZoomStart - 1 + event.scale; 243 - this.setNewZoom(newZoom, point, { shouldUpdatePosition: true }); 244 - }; 245 - 246 - onGestureStart = (event: GestureEvent) => { 247 - event.preventDefault(); 248 - this.currentDoc.addEventListener( 249 - "gesturechange", 250 - this.onGestureMove as EventListener 251 - ); 252 - this.currentDoc.addEventListener( 253 - "gestureend", 254 - this.onGestureEnd as EventListener 255 - ); 256 - this.gestureZoomStart = this.props.zoom; 257 - }; 258 - 259 - onMediaLoad = () => { 260 - const cropSize = this.computeSizes(); 261 - this.setNewZoom(1, { x: 0, y: 0 }); 262 - 263 - if (cropSize) { 264 - this.emitCropData(); 265 - this.setInitialCrop(); 266 - } 267 - }; 268 - 269 - onMouseDown = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => { 270 - event.preventDefault(); 271 - this.currentDoc.addEventListener("mousemove", this.onMouseMove); 272 - this.currentDoc.addEventListener("mouseup", this.onDragStopped); 273 - this.onDragStart(ImageCropper.getMousePoint(event)); 274 - }; 275 - 276 - onMouseMove = (event: MouseEvent) => 277 - this.onDrag(ImageCropper.getMousePoint(event)); 278 - 279 - onTouchMove = (event: TouchEvent) => { 280 - // Prevent whole page from scrolling on iOS. 281 - event.preventDefault(); 282 - if (event.touches.length === 2) { 283 - this.onPinchMove(event); 284 - } else if (event.touches.length === 1) { 285 - this.onDrag(ImageCropper.getTouchPoint(event.touches[0])); 286 - } 287 - }; 288 - 289 - onTouchStart = (event: React.TouchEvent<HTMLDivElement>) => { 290 - this.isTouching = true; 291 - this.currentDoc.addEventListener("touchmove", this.onTouchMove, { 292 - passive: false 293 - }); // iOS 11 now defaults to passive: true 294 - this.currentDoc.addEventListener("touchend", this.onDragStopped); 295 - 296 - if (event.touches.length === 2) { 297 - this.onPinchStart(event); 298 - } else if (event.touches.length === 1) { 299 - this.onDragStart(ImageCropper.getTouchPoint(event.touches[0])); 300 - } 301 - }; 302 - 303 - onWheel = (event: WheelEvent) => { 304 - event.preventDefault(); 305 - const point = ImageCropper.getMousePoint(event); 306 - const { spinY } = normalizeWheel(event); 307 - const newZoom = this.props.zoom * this.props.zoomSpeed ** -spinY; 308 - this.setNewZoom(newZoom, point, { shouldUpdatePosition: true }); 309 - 310 - if (!this.state.hasWheelJustStarted) { 311 - this.setState({ hasWheelJustStarted: true }, () => {}); 312 - } 313 - 314 - if (this.wheelTimer) { 315 - clearTimeout(this.wheelTimer); 316 - } 317 - this.wheelTimer = this.currentWindow.setTimeout( 318 - () => this.setState({ hasWheelJustStarted: false }, () => {}), 319 - 250 320 - ); 321 - }; 322 - 323 - // prevent Safari on iOS >= 10 to zoom the page 324 - preventZoomSafari = (event: Event) => event.preventDefault(); 325 - 326 - rafDragTimeout: null | number = null; 327 - 328 - rafPinchTimeout: null | number = null; 329 - 330 - recomputeCropPosition = () => { 331 - const cropPosition = this.getAbsolutePosition( 332 - this.props.cropPositionPercent 333 - ); 334 - const newPosition = restrictPosition( 335 - cropPosition, 336 - this.mediaSize, 337 - this.props.cropSize, 338 - this.props.zoom 339 - ); 340 - const newPercentagePosition = this.getPercentPosition(newPosition); 341 - this.props.onCropChange(newPercentagePosition); 342 - this.emitCropData(); 343 - }; 344 - 345 - setInitialCrop = () => { 346 - this.props.onCropChange({ x: 0, y: 0 }); 347 - }; 348 - 349 - setNewZoom = ( 350 - zoom: number, 351 - point: null | Point, 352 - { shouldUpdatePosition = true } = {} 353 - ) => { 354 - if (!this.props.onZoomChange) { 355 - return; 356 - } 357 - const fitWidth = 358 - this.mediaSize.width / this.mediaSize.height < 359 - this.props.cropSize.width / this.props.cropSize.height; 360 - const mediaToTargetSizeRatio = fitWidth 361 - ? this.mediaSize.naturalWidth / this.props.targetSize.width 362 - : this.mediaSize.naturalHeight / this.props.targetSize.height; 363 - const maxOutputBlurryness = 2; 364 - const minZoom = 1; 365 - const maxZoom = Math.max( 366 - minZoom, 367 - mediaToTargetSizeRatio * maxOutputBlurryness 368 - ); 369 - const newZoom = restrictValue(zoom, minZoom, maxZoom); 370 - 371 - if (shouldUpdatePosition) { 372 - const zoomPoint = point 373 - ? this.getPointOnContainer(point) 374 - : { x: 0, y: 0 }; 375 - const zoomTarget = this.getPointOnMedia(zoomPoint); 376 - const requestedPosition = { 377 - x: zoomTarget.x * newZoom - zoomPoint.x, 378 - y: zoomTarget.y * newZoom - zoomPoint.y 379 - }; 380 - 381 - const newPosition = restrictPosition( 382 - requestedPosition, 383 - this.mediaSize, 384 - this.props.cropSize, 385 - newZoom 386 - ); 387 - const newPercentagePosition = this.getPercentPosition(newPosition); 388 - this.props.onCropChange(newPercentagePosition); 389 - } 390 - this.props.onZoomChange(newZoom, maxZoom); 391 - }; 392 - 393 - state: State = { 394 - hasWheelJustStarted: false 395 - }; 396 - 397 - wheelTimer: null | number = null; 398 - 399 - componentDidMount() { 400 - if (this.containerRef) { 401 - if (this.containerRef.ownerDocument) { 402 - this.currentDoc = this.containerRef.ownerDocument; 403 - } 404 - if (this.currentDoc.defaultView) { 405 - this.currentWindow = this.currentDoc.defaultView; 406 - } 407 - this.containerRef.addEventListener("wheel", this.onWheel, { 408 - passive: false 409 - }); 410 - this.containerRef.addEventListener( 411 - "gesturestart", 412 - this.onGestureStart as EventListener 413 - ); 414 - } 415 - 416 - // when rendered via SSR, the image can already be loaded and its onLoad callback will never be called 417 - if (this.imageRef.current?.complete) { 418 - this.onMediaLoad(); 419 - } 420 - } 421 - 422 - componentDidUpdate(prevProps: CropperProps) { 423 - if (prevProps.zoom !== this.props.zoom) { 424 - this.recomputeCropPosition(); 425 - } 426 - if ( 427 - this.props.cropSize.width !== prevProps.cropSize.width || 428 - this.props.cropSize.height !== prevProps.cropSize.height 429 - ) { 430 - this.computeSizes(); 431 - } 432 - } 433 - 434 - componentWillUnmount() { 435 - if (this.containerRef) { 436 - this.containerRef.removeEventListener( 437 - "gesturestart", 438 - this.preventZoomSafari 439 - ); 440 - } 441 - this.cleanEvents(); 442 - this.clearScrollEvent(); 443 - } 444 - 445 - onPinchMove(event: TouchEvent) { 446 - const pointA = ImageCropper.getTouchPoint(event.touches[0]); 447 - const pointB = ImageCropper.getTouchPoint(event.touches[1]); 448 - const center = getMidpoint(pointA, pointB); 449 - this.onDrag(center); 450 - 451 - if (this.rafPinchTimeout) { 452 - this.currentWindow.cancelAnimationFrame(this.rafPinchTimeout); 453 - } 454 - this.rafPinchTimeout = this.currentWindow.requestAnimationFrame(() => { 455 - const distance = getDistanceBetweenPoints(pointA, pointB); 456 - const newZoom = this.props.zoom * (distance / this.lastPinchDistance); 457 - this.setNewZoom(newZoom, center, { shouldUpdatePosition: false }); 458 - this.lastPinchDistance = distance; 459 - }); 460 - } 461 - 462 - onPinchStart(event: React.TouchEvent<HTMLDivElement>) { 463 - const pointA = ImageCropper.getTouchPoint(event.touches[0]); 464 - const pointB = ImageCropper.getTouchPoint(event.touches[1]); 465 - this.lastPinchDistance = getDistanceBetweenPoints(pointA, pointB); 466 - this.onDragStart(getMidpoint(pointA, pointB)); 467 - } 468 - 469 - render() { 470 - const { 471 - borderSize, 472 - cropPositionPercent: { x, y }, 473 - cropSize: size, 474 - image, 475 - zoom 476 - } = this.props; 477 - const fitWidth = 478 - this.mediaSize.naturalWidth / this.mediaSize.naturalHeight < 479 - this.props.cropSize.width / this.props.cropSize.height; 480 - 481 - return ( 482 - <div 483 - className="rounded-lg" 484 - style={{ 485 - height: size.height + borderSize * 2, 486 - overflow: "hidden", 487 - padding: borderSize, 488 - width: size.width + borderSize * 2 489 - }} 490 - > 491 - <div 492 - className="relative" 493 - style={{ height: size.height, width: size.width }} 494 - > 495 - <div 496 - className={cn("reactEasyCrop_Container")} 497 - onMouseDown={this.onMouseDown} 498 - onTouchStart={this.onTouchStart} 499 - ref={(el) => (this.containerRef = el) as any} 500 - > 501 - {image && ( 502 - <img 503 - alt="" 504 - className={cn( 505 - "reactEasyCrop_Image", 506 - fitWidth 507 - ? "reactEasyCrop_Cover_Horizontal" 508 - : "reactEasyCrop_Cover_Vertical" 509 - )} 510 - onLoad={this.onMediaLoad} 511 - ref={this.imageRef} 512 - src={image} 513 - style={{ transform: `translate(${x}%, ${y}%) scale(${zoom})` }} 514 - /> 515 - )} 516 - <div 517 - className={cn( 518 - "border-2 border-gray-500", 519 - "reactEasyCrop_CropArea" 520 - )} 521 - style={{ 522 - boxShadow: `0 0 0 ${borderSize}px`, 523 - color: "#bbba", 524 - height: size.height, 525 - width: size.width 526 - }} 527 - /> 528 - </div> 529 - </div> 530 - </div> 531 - ); 532 - } 533 - } 534 - 535 - export default ImageCropper;
-21
packages/image-cropper/LICENSE
··· 1 - MIT License 2 - 3 - Copyright (c) 2022 Valentin Hervieu 4 - 5 - Permission is hereby granted, free of charge, to any person obtaining a copy 6 - of this software and associated documentation files (the "Software"), to deal 7 - in the Software without restriction, including without limitation the rights 8 - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 - copies of the Software, and to permit persons to whom the Software is 10 - furnished to do so, subject to the following conditions: 11 - 12 - The above copyright notice and this permission notice shall be included in all 13 - copies or substantial portions of the Software. 14 - 15 - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 - SOFTWARE.
-117
packages/image-cropper/cropUtils.ts
··· 1 - import type { Area, MediaSize, Point, Size } from "./types"; 2 - 3 - export const restrictValue = (value: number, min: number, max: number) => { 4 - return Math.min(Math.max(value, min), max); 5 - }; 6 - 7 - const restrictPositionCoord = ( 8 - position: number, 9 - mediaSize: number, 10 - cropSize: number, 11 - zoom: number 12 - ): number => { 13 - const maxPosition = (mediaSize * zoom) / 2 - cropSize / 2; 14 - return restrictValue(position, -maxPosition, maxPosition); 15 - }; 16 - 17 - export const restrictPosition = ( 18 - position: Point, 19 - mediaSize: Size, 20 - cropSize: Size, 21 - zoom: number 22 - ): Point => { 23 - return { 24 - x: restrictPositionCoord(position.x, mediaSize.width, cropSize.width, zoom), 25 - y: restrictPositionCoord( 26 - position.y, 27 - mediaSize.height, 28 - cropSize.height, 29 - zoom 30 - ) 31 - }; 32 - }; 33 - 34 - export const getDistanceBetweenPoints = (pointA: Point, pointB: Point) => { 35 - return Math.sqrt((pointA.y - pointB.y) ** 2 + (pointA.x - pointB.x) ** 2); 36 - }; 37 - 38 - export const computeCroppedArea = ( 39 - cropPosition: Point, 40 - cropSize: Size, 41 - mediaSize: MediaSize, 42 - zoom: number 43 - ): { croppedAreaPixels: Area } => { 44 - const mediaScale = mediaSize.naturalWidth / mediaSize.width; 45 - const fitWidth = 46 - mediaSize.width / mediaSize.height < cropSize.width / cropSize.height; 47 - const cropSizePixels = fitWidth 48 - ? { 49 - height: 50 - (mediaSize.naturalWidth * (cropSize.height / cropSize.width)) / zoom, 51 - width: mediaSize.naturalWidth / zoom 52 - } 53 - : { 54 - height: mediaSize.naturalHeight / zoom, 55 - width: 56 - (mediaSize.naturalHeight * (cropSize.width / cropSize.height)) / zoom 57 - }; 58 - 59 - const cropAreaCenterPixelX = (-cropPosition.x * mediaScale) / zoom; 60 - const cropAreaCenterPixelY = (-cropPosition.y * mediaScale) / zoom; 61 - const croppedAreaPixels = { 62 - ...cropSizePixels, 63 - x: 64 - cropAreaCenterPixelX - 65 - cropSizePixels.width / 2 + 66 - mediaSize.naturalWidth / 2, 67 - y: 68 - cropAreaCenterPixelY - 69 - cropSizePixels.height / 2 + 70 - mediaSize.naturalHeight / 2 71 - }; 72 - return { croppedAreaPixels }; 73 - }; 74 - 75 - export const getMidpoint = (a: Point, b: Point): Point => { 76 - return { 77 - x: (b.x + a.x) / 2, 78 - y: (b.y + a.y) / 2 79 - }; 80 - }; 81 - 82 - const createImage = (url: string): Promise<HTMLImageElement> => 83 - new Promise((resolve, reject) => { 84 - const image = new Image(); 85 - image.addEventListener("load", () => resolve(image)); 86 - image.addEventListener("error", (error) => reject(error)); 87 - image.src = url; 88 - }); 89 - 90 - export const getCroppedImg = async ( 91 - imageSrc: string, 92 - pixelCrop: Area | null 93 - ): Promise<HTMLCanvasElement | null> => { 94 - const image = await createImage(imageSrc); 95 - const canvas = document.createElement("canvas"); 96 - const ctx = canvas.getContext("2d"); 97 - if (!ctx || !pixelCrop) { 98 - return null; 99 - } 100 - 101 - canvas.width = image.width; 102 - canvas.height = image.height; 103 - ctx.drawImage(image, 0, 0); 104 - 105 - const data = ctx.getImageData( 106 - pixelCrop.x, 107 - pixelCrop.y, 108 - pixelCrop.width, 109 - pixelCrop.height 110 - ); 111 - 112 - canvas.width = pixelCrop.width; 113 - canvas.height = pixelCrop.height; 114 - ctx.putImageData(data, 0, 0); 115 - 116 - return canvas; 117 - };
-21
packages/image-cropper/package.json
··· 1 - { 2 - "name": "@hey/image-cropper", 3 - "version": "0.0.0", 4 - "private": true, 5 - "license": "AGPL-3.0", 6 - "scripts": { 7 - "typecheck": "tsc --pretty" 8 - }, 9 - "dependencies": { 10 - "@hey/ui": "workspace:*", 11 - "normalize-wheel": "^1.0.1", 12 - "rc-slider": "^11.1.8", 13 - "react": "^19.0.0" 14 - }, 15 - "devDependencies": { 16 - "@hey/config": "workspace:*", 17 - "@types/normalize-wheel": "^1.0.4", 18 - "@types/react": "^19.0.12", 19 - "typescript": "^5.7.3" 20 - } 21 - }
-37
packages/image-cropper/styles.css
··· 1 - .reactEasyCrop_Container { 2 - position: absolute; 3 - top: 0; 4 - left: 0; 5 - right: 0; 6 - bottom: 0; 7 - user-select: none; 8 - touch-action: none; 9 - cursor: move; 10 - display: flex; 11 - justify-content: center; 12 - align-items: center; 13 - } 14 - 15 - .reactEasyCrop_Image { 16 - will-change: transform; /* this improves performances and prevent painting issues on iOS Chrome */ 17 - } 18 - 19 - .reactEasyCrop_Cover_Horizontal { 20 - width: 100%; 21 - height: auto; 22 - max-height: none; 23 - } 24 - .reactEasyCrop_Cover_Vertical { 25 - width: auto; 26 - max-width: none; 27 - height: 100%; 28 - } 29 - 30 - .reactEasyCrop_CropArea { 31 - position: absolute; 32 - left: 50%; 33 - top: 50%; 34 - transform: translate(-50%, -50%); 35 - box-sizing: border-box; 36 - overflow: hidden; 37 - }
-3
packages/image-cropper/tsconfig.json
··· 1 - { 2 - "extends": "@hey/config/react.tsconfig.json" 3 - }
-23
packages/image-cropper/types.d.ts
··· 1 - export interface Size { 2 - height: number; 3 - width: number; 4 - } 5 - 6 - export interface MediaSize { 7 - height: number; 8 - naturalHeight: number; 9 - naturalWidth: number; 10 - width: number; 11 - } 12 - 13 - export interface Point { 14 - x: number; 15 - y: number; 16 - } 17 - 18 - export interface Area { 19 - height: number; 20 - width: number; 21 - x: number; 22 - y: number; 23 - }
+31 -107
pnpm-lock.yaml
··· 75 75 '@hey/helpers': 76 76 specifier: workspace:* 77 77 version: link:../../packages/helpers 78 - '@hey/image-cropper': 79 - specifier: workspace:* 80 - version: link:../../packages/image-cropper 81 78 '@hey/indexer': 82 79 specifier: workspace:* 83 80 version: link:../../packages/indexer ··· 144 141 prosekit: 145 142 specifier: ^0.12.1 146 143 version: 0.12.1(@shikijs/types@3.2.1)(@types/hast@3.0.4)(preact@10.26.4)(prosemirror-model@1.25.0)(prosemirror-state@1.4.3)(prosemirror-transform@1.10.3)(prosemirror-view@1.38.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 147 - rc-slider: 148 - specifier: ^11.1.8 149 - version: 11.1.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 150 144 react: 151 145 specifier: ^19.0.0 152 146 version: 19.0.0 ··· 159 153 react-dom: 160 154 specifier: ^19.0.0 161 155 version: 19.0.0(react@19.0.0) 156 + react-easy-crop: 157 + specifier: ^5.4.1 158 + version: 5.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 162 159 react-hot-toast: 163 160 specifier: ^2.5.2 164 161 version: 2.5.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) ··· 201 198 unist-util-visit-parents: 202 199 specifier: ^6.0.1 203 200 version: 6.0.1 204 - use-resize-observer: 205 - specifier: ^9.1.0 206 - version: 9.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 207 201 viem: 208 202 specifier: ^2.23.15 209 203 version: 2.23.15(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2) ··· 336 330 specifier: ^5.7.3 337 331 version: 5.8.2 338 332 339 - packages/image-cropper: 340 - dependencies: 341 - '@hey/ui': 342 - specifier: workspace:* 343 - version: link:../ui 344 - normalize-wheel: 345 - specifier: ^1.0.1 346 - version: 1.0.1 347 - rc-slider: 348 - specifier: ^11.1.8 349 - version: 11.1.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 350 - react: 351 - specifier: ^19.0.0 352 - version: 19.0.0 353 - devDependencies: 354 - '@hey/config': 355 - specifier: workspace:* 356 - version: link:../config 357 - '@types/normalize-wheel': 358 - specifier: ^1.0.4 359 - version: 1.0.4 360 - '@types/react': 361 - specifier: ^19.0.12 362 - version: 19.0.12 363 - typescript: 364 - specifier: ^5.7.3 365 - version: 5.8.2 366 - 367 333 packages/indexer: 368 334 dependencies: 369 335 '@apollo/client': ··· 1816 1782 '@jridgewell/trace-mapping@0.3.9': 1817 1783 resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} 1818 1784 1819 - '@juggle/resize-observer@3.4.0': 1820 - resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} 1821 - 1822 1785 '@kurkle/color@0.3.4': 1823 1786 resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} 1824 1787 ··· 3157 3120 '@types/node@22.13.13': 3158 3121 resolution: {integrity: sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==} 3159 3122 3160 - '@types/normalize-wheel@1.0.4': 3161 - resolution: {integrity: sha512-iclKEmOclXH2LGVkMkdal0+ffJphB3kbazakec96z1hW/CfJYmsZNFYLAmkpzePxKoKewXp2HSlsN6G4SG0b0g==} 3162 - 3163 3123 '@types/omit-deep@0.3.2': 3164 3124 resolution: {integrity: sha512-sNJTN6nxoicS3pwMqUU+ToO0Uf0RobYFr7hNjUnQTQ2WiQ6pyox4J05KTJm60Bn+XpmzWgmHwaogqcI3aJZENQ==} 3165 3125 ··· 3687 3647 chokidar@4.0.3: 3688 3648 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} 3689 3649 engines: {node: '>= 14.16.0'} 3690 - 3691 - classnames@2.5.1: 3692 - resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} 3693 3650 3694 3651 clean-stack@2.2.0: 3695 3652 resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} ··· 5875 5832 resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} 5876 5833 engines: {node: '>= 0.8'} 5877 5834 5878 - rc-slider@11.1.8: 5879 - resolution: {integrity: sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ==} 5880 - engines: {node: '>=8.x'} 5881 - peerDependencies: 5882 - react: '>=16.9.0' 5883 - react-dom: '>=16.9.0' 5884 - 5885 - rc-util@5.44.4: 5886 - resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==} 5887 - peerDependencies: 5888 - react: '>=16.9.0' 5889 - react-dom: '>=16.9.0' 5890 - 5891 5835 react-aptor@2.0.0: 5892 5836 resolution: {integrity: sha512-YnCayokuhAwmBBP4Oc0bbT2l6ApfsjbY3DEEVUddIKZEBlGl1npzjHHzWnSqWuboSbMZvRqUM01Io9yiIp1wcg==} 5893 5837 engines: {node: '>=12.7.0'} ··· 5913 5857 resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==} 5914 5858 peerDependencies: 5915 5859 react: ^19.0.0 5860 + 5861 + react-easy-crop@5.4.1: 5862 + resolution: {integrity: sha512-Djtsi7bWO75vkKYkVxNRrJWY69pXLahIAkUN0mmt9cXNnaq2tpG59ctSY6P7ipJgBc7COJDRMRuwb2lYwtACNQ==} 5863 + peerDependencies: 5864 + react: '>=16.4.0' 5865 + react-dom: '>=16.4.0' 5916 5866 5917 5867 react-hook-form@7.54.2: 5918 5868 resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==} ··· 6746 6696 react: '>=18.0.0' 6747 6697 scheduler: '>=0.19.0' 6748 6698 6749 - use-resize-observer@9.1.0: 6750 - resolution: {integrity: sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==} 6751 - peerDependencies: 6752 - react: 16.8.0 - 18 6753 - react-dom: 16.8.0 - 18 6754 - 6755 6699 use-sidecar@1.1.3: 6756 6700 resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} 6757 6701 engines: {node: '>=10'} ··· 8760 8704 '@graphql-tools/optimize@1.4.0(graphql@16.10.0)': 8761 8705 dependencies: 8762 8706 graphql: 16.10.0 8763 - tslib: 2.6.3 8707 + tslib: 2.8.1 8764 8708 8765 8709 '@graphql-tools/optimize@2.0.0(graphql@16.10.0)': 8766 8710 dependencies: 8767 8711 graphql: 16.10.0 8768 - tslib: 2.6.3 8712 + tslib: 2.8.1 8769 8713 8770 8714 '@graphql-tools/prisma-loader@8.0.17(@types/node@22.13.13)(bufferutil@4.0.9)(graphql@16.10.0)(utf-8-validate@5.0.10)': 8771 8715 dependencies: ··· 8800 8744 '@ardatan/relay-compiler': 12.0.0(graphql@16.10.0) 8801 8745 '@graphql-tools/utils': 9.2.1(graphql@16.10.0) 8802 8746 graphql: 16.10.0 8803 - tslib: 2.6.3 8747 + tslib: 2.8.1 8804 8748 transitivePeerDependencies: 8805 8749 - encoding 8806 8750 - supports-color ··· 8810 8754 '@ardatan/relay-compiler': 12.0.3(graphql@16.10.0) 8811 8755 '@graphql-tools/utils': 10.8.6(graphql@16.10.0) 8812 8756 graphql: 16.10.0 8813 - tslib: 2.6.3 8757 + tslib: 2.8.1 8814 8758 transitivePeerDependencies: 8815 8759 - encoding 8816 8760 ··· 8855 8799 '@graphql-tools/utils@8.13.1(graphql@16.10.0)': 8856 8800 dependencies: 8857 8801 graphql: 16.10.0 8858 - tslib: 2.6.3 8802 + tslib: 2.8.1 8859 8803 8860 8804 '@graphql-tools/utils@9.2.1(graphql@16.10.0)': 8861 8805 dependencies: 8862 8806 '@graphql-typed-document-node/core': 3.2.0(graphql@16.10.0) 8863 8807 graphql: 16.10.0 8864 - tslib: 2.6.3 8808 + tslib: 2.8.1 8865 8809 8866 8810 '@graphql-tools/wrap@10.0.34(graphql@16.10.0)': 8867 8811 dependencies: ··· 9000 8944 '@jridgewell/resolve-uri': 3.1.2 9001 8945 '@jridgewell/sourcemap-codec': 1.5.0 9002 8946 9003 - '@juggle/resize-observer@3.4.0': {} 9004 - 9005 8947 '@kurkle/color@0.3.4': {} 9006 8948 9007 8949 '@kwsites/file-exists@1.1.1': ··· 10831 10773 dependencies: 10832 10774 undici-types: 6.20.0 10833 10775 10834 - '@types/normalize-wheel@1.0.4': {} 10835 - 10836 10776 '@types/omit-deep@0.3.2': {} 10837 10777 10838 10778 '@types/phoenix@1.6.6': {} ··· 11740 11680 chokidar@4.0.3: 11741 11681 dependencies: 11742 11682 readdirp: 4.1.2 11743 - 11744 - classnames@2.5.1: {} 11745 11683 11746 11684 clean-stack@2.2.0: {} 11747 11685 ··· 12977 12915 12978 12916 is-lower-case@2.0.2: 12979 12917 dependencies: 12980 - tslib: 2.6.3 12918 + tslib: 2.8.1 12981 12919 12982 12920 is-number@7.0.0: {} 12983 12921 ··· 13018 12956 13019 12957 is-upper-case@2.0.2: 13020 12958 dependencies: 13021 - tslib: 2.6.3 12959 + tslib: 2.8.1 13022 12960 13023 12961 is-windows@1.0.2: {} 13024 12962 ··· 13241 13179 13242 13180 lower-case-first@2.0.2: 13243 13181 dependencies: 13244 - tslib: 2.6.3 13182 + tslib: 2.8.1 13245 13183 13246 13184 lower-case@2.0.2: 13247 13185 dependencies: 13248 - tslib: 2.6.3 13186 + tslib: 2.8.1 13249 13187 13250 13188 lru-cache@10.4.3: {} 13251 13189 ··· 14246 14184 iconv-lite: 0.4.24 14247 14185 unpipe: 1.0.0 14248 14186 14249 - rc-slider@11.1.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0): 14250 - dependencies: 14251 - '@babel/runtime': 7.27.0 14252 - classnames: 2.5.1 14253 - rc-util: 5.44.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14254 - react: 19.0.0 14255 - react-dom: 19.0.0(react@19.0.0) 14256 - 14257 - rc-util@5.44.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): 14258 - dependencies: 14259 - '@babel/runtime': 7.27.0 14260 - react: 19.0.0 14261 - react-dom: 19.0.0(react@19.0.0) 14262 - react-is: 18.3.1 14263 - 14264 14187 react-aptor@2.0.0(react@19.0.0): 14265 14188 optionalDependencies: 14266 14189 react: 19.0.0 ··· 14280 14203 dependencies: 14281 14204 react: 19.0.0 14282 14205 scheduler: 0.25.0 14206 + 14207 + react-easy-crop@5.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): 14208 + dependencies: 14209 + normalize-wheel: 1.0.1 14210 + react: 19.0.0 14211 + react-dom: 19.0.0(react@19.0.0) 14212 + tslib: 2.8.1 14283 14213 14284 14214 react-hook-form@7.54.2(react@19.0.0): 14285 14215 dependencies: ··· 14769 14699 14770 14700 sponge-case@1.0.1: 14771 14701 dependencies: 14772 - tslib: 2.6.3 14702 + tslib: 2.8.1 14773 14703 14774 14704 stack-trace@0.0.10: {} 14775 14705 ··· 14903 14833 14904 14834 swap-case@2.0.2: 14905 14835 dependencies: 14906 - tslib: 2.6.3 14836 + tslib: 2.8.1 14907 14837 14908 14838 symbol-observable@4.0.0: {} 14909 14839 ··· 14964 14894 14965 14895 title-case@3.0.3: 14966 14896 dependencies: 14967 - tslib: 2.6.3 14897 + tslib: 2.8.1 14968 14898 14969 14899 titleize@3.0.0: {} 14970 14900 ··· 15147 15077 15148 15078 upper-case-first@2.0.2: 15149 15079 dependencies: 15150 - tslib: 2.6.3 15080 + tslib: 2.8.1 15151 15081 15152 15082 upper-case@2.0.2: 15153 15083 dependencies: 15154 - tslib: 2.6.3 15084 + tslib: 2.8.1 15155 15085 15156 15086 url-polyfill@1.1.13: {} 15157 15087 ··· 15168 15098 dependencies: 15169 15099 react: 19.0.0 15170 15100 scheduler: 0.25.0 15171 - 15172 - use-resize-observer@9.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): 15173 - dependencies: 15174 - '@juggle/resize-observer': 3.4.0 15175 - react: 19.0.0 15176 - react-dom: 19.0.0(react@19.0.0) 15177 15101 15178 15102 use-sidecar@1.1.3(@types/react@19.0.12)(react@19.0.0): 15179 15103 dependencies: