import * as SliderPrimitive from "@radix-ui/react-slider"; import type { FormEvent } from "react"; import { useCallback, useEffect, useState } from "react"; import Cropper from "react-easy-crop"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button, Dialog, DialogClose, DialogContent, DialogTrigger, DialogFooter } from "../.."; import { showToast } from "../toast"; type ReadAsMethod = "readAsText" | "readAsDataURL" | "readAsArrayBuffer" | "readAsBinaryString"; type UseFileReaderProps = { method: ReadAsMethod; onLoad?: (result: unknown) => void; }; type Area = { width: number; height: number; x: number; y: number; }; const MAX_IMAGE_SIZE = 512; const useFileReader = (options: UseFileReaderProps) => { const { method = "readAsText", onLoad } = options; const [file, setFile] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); useEffect(() => { if (!file && result) { setResult(null); } }, [file, result]); useEffect(() => { if (!file) { return; } const reader = new FileReader(); reader.onloadstart = () => setLoading(true); reader.onloadend = () => setLoading(false); reader.onerror = () => setError(reader.error); reader.onload = (e: ProgressEvent) => { setResult(e.target?.result ?? null); if (onLoad) { onLoad(e.target?.result ?? null); } }; reader[method](file); }, [file, method, onLoad]); return [{ result, error, file, loading }, setFile] as const; }; type ImageUploaderProps = { id: string; buttonMsg: string; handleAvatarChange: (imageSrc: string) => void; imageSrc?: string; target: string; }; interface FileEvent extends FormEvent { target: EventTarget & T; } // This is separate to prevent loading the component until file upload function CropContainer({ onCropComplete, imageSrc, }: { imageSrc: string; onCropComplete: (croppedAreaPixels: Area) => void; }) { const { t } = useLocale(); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const handleZoomSliderChange = (value: number) => { value < 1 ? setZoom(1) : setZoom(value); }; return (
onCropComplete(croppedAreaPixels)} onZoomChange={setZoom} />
); } export default function ImageUploader({ target, id, buttonMsg, handleAvatarChange, ...props }: ImageUploaderProps) { const { t } = useLocale(); const [imageSrc, setImageSrc] = useState(null); const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); const [{ result }, setFile] = useFileReader({ method: "readAsDataURL", }); useEffect(() => { if (props.imageSrc) setImageSrc(props.imageSrc); }, [props.imageSrc]); const onInputFile = (e: FileEvent) => { if (!e.target.files?.length) { return; } const limit = 5 * 1000000; // max limit 5mb const file = e.target.files[0]; if (file.size > limit) { showToast(t("image_size_limit_exceed"), "error"); } else { setFile(file); } }; const showCroppedImage = useCallback( async (croppedAreaPixels: Area | null) => { try { if (!croppedAreaPixels) return; const croppedImage = await getCroppedImg( result as string /* result is always string when using readAsDataUrl */, croppedAreaPixels ); setImageSrc(croppedImage); handleAvatarChange(croppedImage); } catch (e) { console.error(e); } }, [result, handleAvatarChange] ); return ( !opened && setFile(null) // unset file on close }>
{!result && (
{!imageSrc && (

{t("no_target", { target })}

)} {imageSrc && ( // eslint-disable-next-line @next/next/no-img-element {target} )}
)} {result && }
showCroppedImage(croppedAreaPixels)}> {t("save")} {t("cancel")}
); } const createImage = (url: string) => new Promise((resolve, reject) => { const image = new Image(); image.addEventListener("load", () => resolve(image)); image.addEventListener("error", (error) => reject(error)); image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox image.src = url; }); async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise { const image = await createImage(imageSrc); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Context is null, this should never happen."); const maxSize = Math.max(image.naturalWidth, image.naturalHeight); const resizeRatio = MAX_IMAGE_SIZE / maxSize < 1 ? Math.max(MAX_IMAGE_SIZE / maxSize, 0.75) : 1; // huh, what? - Having this turned off actually improves image quality as otherwise anti-aliasing is applied // this reduces the quality of the image overall because it anti-aliases the existing, copied image; blur results ctx.imageSmoothingEnabled = false; // pixelCrop is always 1:1 - width = height canvas.width = canvas.height = Math.min(maxSize * resizeRatio, pixelCrop.width); ctx.drawImage( image, pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height, 0, 0, canvas.width, canvas.height ); // on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75 if (resizeRatio <= 0.75) { // With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75. return getCroppedImg(canvas.toDataURL("image/png"), { width: canvas.width, height: canvas.height, x: 0, y: 0, }); } return canvas.toDataURL("image/png"); } const Slider = ({ value, label, changeHandler, ...props }: Omit & { value: number; label: string; changeHandler: (value: number) => void; }) => ( changeHandler(value[0] ?? value)} {...props}> );