Coursemology/coursemology2

View on GitHub
client/app/lib/components/core/ImageCropper/index.tsx

Summary

Maintainability
A
3 hrs
Test Coverage
import { ReactEventHandler, useRef, useState } from 'react';
import useEmitterFactory, { Emits } from 'react-emitter-factory';
import ReactCrop, { PercentCrop, PixelCrop } from 'react-image-crop';
import { RotateRight } from '@mui/icons-material';
import { Slider } from '@mui/material';

import { centerAspectCrop, getImage } from './utils';
import 'react-image-crop/dist/ReactCrop.css';

const DEFAULT_CROP: PercentCrop = {
  unit: '%',
  x: 25,
  y: 25,
  width: 50,
  height: 50,
};

export interface ImageCropperEmitter {
  /**
   * Resets the cropper to allow initial crop to be generated on new `src` load.
   */
  resetImage?: () => void;

  /**
   * Asynchronously generate an image file of the final cropped image.
   */
  getImage?: () => Promise<Blob | undefined>;
}

interface ImageCropperProps extends Emits<ImageCropperEmitter> {
  src?: string;
  alt?: string;
  circular?: boolean;
  grids?: boolean;
  aspect?: number;
  onLoadError?: () => void;
  type?: string;
}

const ImageCropper = (props: ImageCropperProps): JSX.Element => {
  const [crop, setCrop] = useState<PercentCrop>();
  const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
  const [rotation, setRotation] = useState(0);
  const imgRef = useRef<HTMLImageElement>(null);

  const generateImage = async (): Promise<Blob | undefined> => {
    if (!imgRef.current || !completedCrop) return undefined;
    return getImage(
      imgRef.current!,
      completedCrop,
      rotation,
      props.type ?? 'image/jpeg',
    );
  };

  const handleImageLoad: ReactEventHandler<HTMLImageElement> = (e) => {
    if (props.aspect) {
      const { width, height } = e.currentTarget;
      setCrop(centerAspectCrop(width, height, props.aspect));
    } else {
      setCrop(DEFAULT_CROP);
    }
  };

  // Must set completedCrop and rotation as dependencies because generateImage
  // captures them as the state changes, except imgRef which persists throughout.
  useEmitterFactory(
    props,
    {
      resetImage: () => setCrop(undefined),
      getImage: () => generateImage(),
    },
    [completedCrop, rotation],
  );

  return (
    <div>
      <ReactCrop
        aspect={props.aspect}
        circularCrop={props.circular}
        crop={crop}
        keepSelection
        onChange={(_, percentCrop): void => setCrop(percentCrop)}
        onComplete={setCompletedCrop}
        ruleOfThirds={props.grids ?? true}
      >
        <img
          ref={imgRef}
          alt={props.alt}
          className="pointer-events-none select-none"
          onError={props.onLoadError}
          onLoad={handleImageLoad}
          src={props.src}
          style={{ transform: `rotate(${rotation}deg)` }}
        />
      </ReactCrop>

      <div className="flex items-center">
        <RotateRight className="mr-8" />

        <Slider
          max={180}
          min={0}
          onChange={(_, angle): void => setRotation(angle as number)}
          value={rotation}
          valueLabelDisplay="auto"
          valueLabelFormat={(degree): string => `${degree}\u00B0`}
        />
      </div>
    </div>
  );
};

export default ImageCropper;