ImageCropModal.tsx 6.0 KB

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