ImageCropModal.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import React, {
  2. FC, useCallback, useEffect, useState,
  3. } from 'react';
  4. import canvasToBlob from 'async-canvas-to-blob';
  5. import { useTranslation } from 'react-i18next';
  6. import ReactCrop from 'react-image-crop';
  7. import {
  8. Modal,
  9. ModalHeader,
  10. ModalBody,
  11. ModalFooter,
  12. } from 'reactstrap';
  13. import { toastError } from '~/client/util/toastr';
  14. import loggerFactory from '~/utils/logger';
  15. import 'react-image-crop/dist/ReactCrop.css';
  16. const logger = loggerFactory('growi:ImageCropModal');
  17. interface ICropOptions {
  18. aspect: number
  19. unit: string,
  20. x: number
  21. y: number
  22. width: number,
  23. height: number,
  24. }
  25. type CropOptions = ICropOptions | null
  26. type Props = {
  27. isShow: boolean,
  28. src: string | ArrayBuffer | null,
  29. onModalClose: () => void,
  30. onImageProcessCompleted: (res: any) => void,
  31. isCircular: boolean,
  32. showCropOption: boolean
  33. }
  34. const ImageCropModal: FC<Props> = (props: Props) => {
  35. const {
  36. isShow, src, onModalClose, onImageProcessCompleted, isCircular, showCropOption,
  37. } = props;
  38. const [imageRef, setImageRef] = useState<HTMLImageElement | null>(null);
  39. const [cropOptions, setCropOtions] = useState<CropOptions>(null);
  40. const [isCropImage, setIsCropImage] = useState<boolean>(true);
  41. const { t } = useTranslation('commons');
  42. const reset = useCallback(() => {
  43. if (imageRef) {
  44. // Some SVG files may not have width and height properties, causing the render size to be 0x0
  45. // Force imageRef to have width and height by create temporary image element then set the imageRef width with tempImage width
  46. // Set imageRef width & height by natural width / height if image has no dimension
  47. if (imageRef.width === 0 || imageRef.height === 0) {
  48. imageRef.width = imageRef.naturalWidth;
  49. imageRef.height = imageRef.naturalHeight;
  50. }
  51. // Get size of Image, min value of width and height
  52. const size = Math.min(imageRef.width, imageRef.height);
  53. setCropOtions({
  54. aspect: 1,
  55. unit: 'px',
  56. x: imageRef.width / 2 - size / 2,
  57. y: imageRef.height / 2 - size / 2,
  58. width: size,
  59. height: size,
  60. });
  61. }
  62. }, [imageRef]);
  63. useEffect(() => {
  64. document.body.style.position = 'static';
  65. setIsCropImage(true);
  66. reset();
  67. }, [reset]);
  68. const onImageLoaded = (image) => {
  69. setImageRef(image);
  70. reset();
  71. return false;
  72. };
  73. const getCroppedImg = async(image: HTMLImageElement, crop: ICropOptions) => {
  74. const {
  75. naturalWidth: imageNaturalWidth, naturalHeight: imageNaturalHeight, width: imageWidth, height: imageHeight,
  76. } = image;
  77. const {
  78. width: cropWidth, height: cropHeight, x, y,
  79. } = crop;
  80. const canvas = document.createElement('canvas');
  81. const scaleX = imageNaturalWidth / imageWidth;
  82. const scaleY = imageNaturalHeight / imageHeight;
  83. canvas.width = cropWidth;
  84. canvas.height = cropHeight;
  85. const ctx = canvas.getContext('2d');
  86. ctx?.drawImage(image, x * scaleX, y * scaleY, cropWidth * scaleX, cropHeight * scaleY, 0, 0, cropWidth, cropHeight);
  87. try {
  88. const blob = await canvasToBlob(canvas);
  89. return blob;
  90. }
  91. catch (err) {
  92. logger.error(err);
  93. toastError(new Error('Failed to draw image'));
  94. }
  95. };
  96. // Convert base64 Image to blob
  97. const convertBase64ToBlob = async(base64Image: string) => {
  98. const base64Response = await fetch(base64Image);
  99. return base64Response.blob();
  100. };
  101. // Clear image and set isImageCrop true on modal close
  102. const onModalCloseHandler = async() => {
  103. setImageRef(null);
  104. onModalClose();
  105. };
  106. // Process and save image
  107. // Cropping image is optional
  108. // If crop is active , the saved image is cropped image (png). Otherwise, the original image will be saved (Original size and file type)
  109. const processAndSaveImage = async() => {
  110. if (imageRef && cropOptions?.width && cropOptions.height) {
  111. const processedImage = isCropImage ? await getCroppedImg(imageRef, cropOptions) : await convertBase64ToBlob(imageRef.src);
  112. // Save image to database
  113. onImageProcessCompleted(processedImage);
  114. }
  115. onModalCloseHandler();
  116. };
  117. return (
  118. <Modal isOpen={isShow} toggle={onModalCloseHandler}>
  119. <ModalHeader tag="h4" toggle={onModalCloseHandler} className="bg-info text-light">
  120. {t('crop_image_modal.image_crop')}
  121. </ModalHeader>
  122. <ModalBody className="my-4">
  123. {
  124. isCropImage
  125. ? (
  126. <ReactCrop
  127. style={{ backgroundColor: 'transparent' }}
  128. src={src}
  129. crop={cropOptions}
  130. onImageLoaded={onImageLoaded}
  131. onChange={crop => setCropOtions(crop)}
  132. circularCrop={isCircular}
  133. />
  134. )
  135. : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
  136. }
  137. </ModalBody>
  138. <ModalFooter>
  139. <button type="button" className="btn btn-outline-danger rounded-pill me-auto" disabled={!isCropImage} onClick={reset}>
  140. {t('commons:Reset')}
  141. </button>
  142. { !showCropOption && (
  143. <div className="me-auto">
  144. <div className="custom-control custom-switch ">
  145. <input
  146. id="cropImageOption"
  147. className="custom-control-input me-auto"
  148. type="checkbox"
  149. checked={isCropImage}
  150. onChange={() => { setIsCropImage(!isCropImage) }}
  151. />
  152. <label className="custom-control-label" htmlFor="cropImageOption">
  153. { t('crop_image_modal.image_crop') }
  154. </label>
  155. </div>
  156. </div>
  157. )
  158. }
  159. <button type="button" className="btn btn-outline-secondary rounded-pill me-2" onClick={onModalCloseHandler}>
  160. {t('crop_image_modal.cancel')}
  161. </button>
  162. <button type="button" className="btn btn-outline-primary rounded-pill" onClick={processAndSaveImage}>
  163. { isCropImage ? t('crop_image_modal.crop') : t('crop_image_modal.save') }
  164. </button>
  165. </ModalFooter>
  166. </Modal>
  167. );
  168. };
  169. export default ImageCropModal;